mirror of
https://github.com/sbrow/envr.git
synced 2026-06-27 10:38:33 -04:00
fix: Added proper help text to all commands.
This commit is contained in:
18
TODOS.md
18
TODOS.md
@@ -10,6 +10,8 @@ Note: These todos can wait until all the subcommands have been ported.
|
||||
|
||||
3. **config.odin:52-54** — `os.user_home_dir` error silently ignored. If it fails, `home` is `""` and all paths become relative (`".envr"` instead of `"~/.envr"`).
|
||||
|
||||
30. **cmd_sync.odin:46-50, 64-68** — Double `db_insert` when `BackedUp`: first insert on line 48, then `db_update_required` is also true for `BackedUp` so second insert runs on line 65. Redundant and wasteful.
|
||||
|
||||
## MEDIUM
|
||||
|
||||
4. **db.odin:29-35** — `make_temp_path` never calls `strings.builder_destroy`. Leaks builder buffer every call.
|
||||
@@ -32,6 +34,12 @@ Note: These todos can wait until all the subcommands have been ported.
|
||||
|
||||
13. [x] **cmd_list.odin:31-35, 58-61** — Uses a `strings.Builder` (never destroyed) for what is just `row.Dir + "/"`. Also `filepath.rel` used where `filepath.base` would suffice since dir is always the parent.
|
||||
|
||||
33. **config.odin:178** — `search_paths` silently ignores `os.user_home_dir` error. If home is empty, `~` isn't expanded. Same class of bug as issue 3.
|
||||
|
||||
34. **table.odin:84-88** — `render_json_rows` creates `map[string]string` per row, copies into dynamic array. `delete(entries)` frees the array but not individual map internals — potential map bucket leak per row.
|
||||
|
||||
35. **prompt.odin:124** — `make([dynamic]bool, len(options))` creates N zero-initialized elements. Works because `false` is the default, but same footgun as original issue 1. Should be `make([dynamic]bool, 0, len(options))`.
|
||||
|
||||
## LOW
|
||||
|
||||
14. [x] **db.odin:338-341** — Unnecessary `strings.clone` before `filepath.dir` (which already returns a slice into the input).
|
||||
@@ -44,6 +52,10 @@ Note: These todos can wait until all the subcommands have been ported.
|
||||
|
||||
19. **main.odin:42-46** — Dynamic array in `fallback_to_go` never deleted. Harmless since process exits.
|
||||
|
||||
36. **cli.odin:59-76** — Single-dash multi-char flags (e.g. `-force`) silently misparse. `-force` becomes flag `f` with value `o`, then `rce` as positional arg. Only `--force` and `-f` work correctly.
|
||||
|
||||
37. **cmd_sync.odin:80, cmd_list.odin:33, cmd_deps.odin:9** — `make([]string, 2)` for table rows never freed. Leaks per row. Defer to memory pass.
|
||||
|
||||
## REFACTOR
|
||||
|
||||
20. **cmd_list.odin** — Non-TTY branch builds `ListEntry` structs and marshals JSON separately. Now that `render_json_rows` (issue 1) accepts an `io.Writer` and uses `json.marshal`, unify both branches to use it. Note: will change JSON keys from `"directory"/"path"` to `"Directory"/"Path"`.
|
||||
@@ -59,3 +71,9 @@ Note: These todos can wait until all the subcommands have been ported.
|
||||
25. Add tests for untested commands.
|
||||
|
||||
26. Add a global --config -c flag to use an alternate config.
|
||||
|
||||
27. version --long Odin only prints version; Go also prints commit hash and build date
|
||||
|
||||
28. 2 scan tests silently skip Low When fd isn't installed, tests pass without actually testing anything. These should use #assert to be sure that fd is in path.
|
||||
|
||||
29. nushell completions?
|
||||
|
||||
181
cli.odin
181
cli.odin
@@ -12,27 +12,29 @@ Command :: struct {
|
||||
}
|
||||
|
||||
CommandInfo :: struct {
|
||||
name: string,
|
||||
usage: string,
|
||||
short: string,
|
||||
long: string,
|
||||
name: string,
|
||||
usage: string,
|
||||
short: string,
|
||||
long: string,
|
||||
aliases: []string,
|
||||
}
|
||||
|
||||
COMMANDS := []CommandInfo{
|
||||
{"init", "envr init", "Set up envr",
|
||||
"The init command generates your initial config and saves it to\n~/.envr/config in JSON format.\n\nDuring setup, you will be prompted to select one or more ssh keys with which to\nencrypt your databse. **Make 100% sure** that you have **a remote copy** of this\nkey somewhere, otherwise your data could be lost forever."},
|
||||
{"scan", "envr scan", "Find and select .env files for backup", ""},
|
||||
{"sync", "envr sync", "Update or restore your env backups", ""},
|
||||
{"backup", "envr backup <path>", "Import a .env file into envr", ""},
|
||||
{"add", "envr add <path>", "Import a .env file into envr", ""},
|
||||
{"restore", "envr restore <path>", "Restore a .env file from the database", ""},
|
||||
{"list", "envr list", "View your tracked files", ""},
|
||||
{"remove", "envr remove <path>", "Remove a .env file from your database", ""},
|
||||
{"check", "envr check [path]", "Check if files are backed up", ""},
|
||||
"The init command generates your initial config and saves it to\n~/.envr/config in JSON format.\n\nDuring setup, you will be prompted to select one or more ssh keys with which to\nencrypt your databse. **Make 100% sure** that you have **a remote copy** of this\nkey somewhere, otherwise your data could be lost forever.",
|
||||
{}},
|
||||
{"scan", "envr scan", "Find and select .env files for backup", "", {}},
|
||||
{"sync", "envr sync", "Update or restore your env backups", "", {}},
|
||||
{"backup", "envr backup <path>", "Import a .env file into envr", "", {"add"}},
|
||||
{"restore", "envr restore <path>", "Restore a .env file from the database", "", {}},
|
||||
{"list", "envr list", "View your tracked files", "", {}},
|
||||
{"remove", "envr remove <path>", "Remove a .env file from your database", "", {}},
|
||||
{"check", "envr check [path]", "Check if files are backed up", "", {}},
|
||||
{"deps", "envr deps", "Check for missing binaries",
|
||||
"envr relies on external binaries for certain functionality.\n\nThe check command reports on which binaries are available and which are not."},
|
||||
{"version", "envr version", "Show envr's version", ""},
|
||||
{"edit-config", "envr edit-config", "Edit your config with your default editor", ""},
|
||||
"envr relies on external binaries for certain functionality.\n\nThe check command reports on which binaries are available and which are not.",
|
||||
{}},
|
||||
{"version", "envr version", "Show envr's version", "", {}},
|
||||
{"edit-config", "envr edit-config", "Edit your config with your default editor", "", {}},
|
||||
}
|
||||
|
||||
parse_args :: proc() -> (cmd: Command, ok: bool) {
|
||||
@@ -102,60 +104,123 @@ find_command :: proc(name: string) -> (CommandInfo, bool) {
|
||||
if c.name == name {
|
||||
return c, true
|
||||
}
|
||||
for a in c.aliases {
|
||||
if a == name {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return CommandInfo{}, false
|
||||
}
|
||||
|
||||
print_command_help :: proc(name: string) {
|
||||
command_help_text :: proc(name: string) -> (string, bool) {
|
||||
info, found := find_command(name)
|
||||
if !found {
|
||||
return "", false
|
||||
}
|
||||
|
||||
b: strings.Builder
|
||||
strings.builder_init(&b)
|
||||
|
||||
fmt.sbprintf(&b, "Usage: %s [flags]\n\n", info.usage)
|
||||
fmt.sbprintf(&b, "%s\n", info.short)
|
||||
|
||||
if len(info.aliases) > 0 {
|
||||
fmt.sbprintf(&b, "\nAliases:\n %s", info.name)
|
||||
for a in info.aliases {
|
||||
fmt.sbprintf(&b, ", %s", a)
|
||||
}
|
||||
fmt.sbprintf(&b, "\n")
|
||||
}
|
||||
|
||||
if len(info.long) > 0 {
|
||||
fmt.sbprintf(&b, "\n%s\n", info.long)
|
||||
}
|
||||
|
||||
fmt.sbprintf(&b, "\nFlags:\n -h, --help help for %s\n", info.name)
|
||||
|
||||
s := strings.clone(strings.to_string(b))
|
||||
strings.builder_destroy(&b)
|
||||
return s, true
|
||||
}
|
||||
|
||||
print_command_help :: proc(name: string) {
|
||||
text, ok := command_help_text(name)
|
||||
if !ok {
|
||||
fmt.printf("Unknown command: %s\n", name)
|
||||
print_usage()
|
||||
return
|
||||
}
|
||||
fmt.printf("Usage: %s\n\n%s\n", info.usage, info.short)
|
||||
if len(info.long) > 0 {
|
||||
fmt.printf("\n%s\n", info.long)
|
||||
fmt.println(text)
|
||||
}
|
||||
|
||||
usage_text :: proc() -> string {
|
||||
b: strings.Builder
|
||||
strings.builder_init(&b)
|
||||
|
||||
fmt.sbprintf(&b, "envr keeps your .env synced to a local, age encrypted database.\n")
|
||||
fmt.sbprintf(&b, "Is a safe and easy way to gather all your .env files in one place where they can\n")
|
||||
fmt.sbprintf(&b, "easily be backed by another tool such as restic or git.\n")
|
||||
fmt.sbprintf(&b, "\n")
|
||||
fmt.sbprintf(&b, "All your data is stored in ~/data.age\n")
|
||||
fmt.sbprintf(&b, "\n")
|
||||
fmt.sbprintf(&b, "Getting started is easy:\n")
|
||||
fmt.sbprintf(&b, "\n")
|
||||
fmt.sbprintf(&b, "1. Create your configuration file and set up encrypted storage:\n")
|
||||
fmt.sbprintf(&b, "\n")
|
||||
fmt.sbprintf(&b, "> envr init\n")
|
||||
fmt.sbprintf(&b, "\n")
|
||||
fmt.sbprintf(&b, "2. Scan for existing .env files:\n")
|
||||
fmt.sbprintf(&b, "\n")
|
||||
fmt.sbprintf(&b, "> envr scan\n")
|
||||
fmt.sbprintf(&b, "\n")
|
||||
fmt.sbprintf(&b, "Select the files you want to back up from the interactive list.\n")
|
||||
fmt.sbprintf(&b, "\n")
|
||||
fmt.sbprintf(&b, "3. Verify that it worked:\n")
|
||||
fmt.sbprintf(&b, "\n")
|
||||
fmt.sbprintf(&b, "> envr list\n")
|
||||
fmt.sbprintf(&b, "\n")
|
||||
fmt.sbprintf(&b, "4. After changing any of your .env files, update the backup with:\n")
|
||||
fmt.sbprintf(&b, "\n")
|
||||
fmt.sbprintf(&b, "> envr sync\n")
|
||||
fmt.sbprintf(&b, "\n")
|
||||
fmt.sbprintf(&b, "5. If you lose a repository, after re-cloning the repo into the same path it was\n")
|
||||
fmt.sbprintf(&b, "at before, restore your backup with:\n")
|
||||
fmt.sbprintf(&b, "\n")
|
||||
fmt.sbprintf(&b, "> envr restore ~/<path to repository>/.env\n")
|
||||
fmt.sbprintf(&b, "\n")
|
||||
fmt.sbprintf(&b, "Usage:\n")
|
||||
fmt.sbprintf(&b, " envr [command]\n")
|
||||
fmt.sbprintf(&b, "\n")
|
||||
fmt.sbprintf(&b, "Available Commands:\n")
|
||||
|
||||
for c in COMMANDS {
|
||||
name_start := len(b.buf)
|
||||
fmt.sbprintf(&b, "%s", c.name)
|
||||
for a in c.aliases {
|
||||
fmt.sbprintf(&b, ", %s", a)
|
||||
}
|
||||
name_len := len(b.buf) - name_start
|
||||
padding := 20 - name_len
|
||||
if padding > 0 {
|
||||
for _ in 0..<padding {
|
||||
strings.write_byte(&b, ' ')
|
||||
}
|
||||
}
|
||||
fmt.sbprintf(&b, " %s\n", c.short)
|
||||
}
|
||||
|
||||
fmt.sbprintf(&b, "\n")
|
||||
fmt.sbprintf(&b, "Flags:\n")
|
||||
fmt.sbprintf(&b, " -h, --help help for envr\n")
|
||||
fmt.sbprintf(&b, "\n")
|
||||
fmt.sbprintf(&b, "Use \"envr [command] --help\" for more information about a command.\n")
|
||||
|
||||
s := strings.clone(strings.to_string(b))
|
||||
strings.builder_destroy(&b)
|
||||
return s
|
||||
}
|
||||
|
||||
print_usage :: proc() {
|
||||
fmt.println("envr - Manage your .env files.")
|
||||
fmt.println("")
|
||||
fmt.println("envr keeps your .env synced to a local, age encrypted database.")
|
||||
fmt.println("Is a safe and easy way to gather all your .env files in one place where they can")
|
||||
fmt.println("easily be backed by another tool such as restic or git.")
|
||||
fmt.println("")
|
||||
fmt.println("All your data is stored in ~/data.age")
|
||||
fmt.println("")
|
||||
fmt.println("Getting started is easy:")
|
||||
fmt.println("")
|
||||
fmt.println("1. Create your configuration file and set up encrypted storage:")
|
||||
fmt.println("")
|
||||
fmt.println("> envr init")
|
||||
fmt.println("")
|
||||
fmt.println("2. Scan for existing .env files:")
|
||||
fmt.println("")
|
||||
fmt.println("> envr scan")
|
||||
fmt.println("")
|
||||
fmt.println("Select the files you want to back up from the interactive list.")
|
||||
fmt.println("")
|
||||
fmt.println("3. Verify that it worked:")
|
||||
fmt.println("")
|
||||
fmt.println("> envr list")
|
||||
fmt.println("")
|
||||
fmt.println("Usage: envr <command> [args]")
|
||||
fmt.println("")
|
||||
fmt.println("Commands:")
|
||||
fmt.println(" init Set up envr")
|
||||
fmt.println(" scan Find and select .env files for backup")
|
||||
fmt.println(" sync Update or restore your env backups")
|
||||
fmt.println(" backup <path> Import a .env file into envr")
|
||||
fmt.println(" restore <path> Restore a .env file from the database")
|
||||
fmt.println(" list View your tracked files")
|
||||
fmt.println(" remove <path> Remove a .env file from your database")
|
||||
fmt.println(" check [path] Check if files are backed up")
|
||||
fmt.println(" deps Check for missing binaries")
|
||||
fmt.println(" version Show envr's version")
|
||||
fmt.println(" edit-config Edit your config with your default editor")
|
||||
fmt.print(usage_text())
|
||||
}
|
||||
|
||||
130
cli_test.odin
Normal file
130
cli_test.odin
Normal file
@@ -0,0 +1,130 @@
|
||||
package main
|
||||
|
||||
import "core:fmt"
|
||||
import "core:strings"
|
||||
import "core:testing"
|
||||
|
||||
@(test)
|
||||
test_usage_text_contains_all_commands :: proc(t: ^testing.T) {
|
||||
text := usage_text()
|
||||
|
||||
for c in COMMANDS {
|
||||
testing.expect(
|
||||
t,
|
||||
strings.contains(text, c.name),
|
||||
fmt.aprintf("usage_text missing command %q", c.name),
|
||||
)
|
||||
for a in c.aliases {
|
||||
testing.expect(
|
||||
t,
|
||||
strings.contains(text, a),
|
||||
fmt.aprintf("usage_text missing alias %q", a),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_usage_text_contains_steps :: proc(t: ^testing.T) {
|
||||
text := usage_text()
|
||||
|
||||
testing.expect(t, strings.contains(text, "1."), "missing step 1")
|
||||
testing.expect(t, strings.contains(text, "2."), "missing step 2")
|
||||
testing.expect(t, strings.contains(text, "3."), "missing step 3")
|
||||
testing.expect(t, strings.contains(text, "4."), "missing step 4")
|
||||
testing.expect(t, strings.contains(text, "5."), "missing step 5")
|
||||
testing.expect(t, strings.contains(text, "> envr sync\n"), "step 4 missing 'envr sync'")
|
||||
testing.expect(
|
||||
t,
|
||||
strings.contains(text, "> envr restore"),
|
||||
"step 5 missing 'envr restore'",
|
||||
)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_usage_text_contains_flags_and_help_hint :: proc(t: ^testing.T) {
|
||||
text := usage_text()
|
||||
|
||||
testing.expect(t, strings.contains(text, "Flags:"), "missing Flags section")
|
||||
testing.expect(t, strings.contains(text, "--help"), "missing --help flag")
|
||||
testing.expect(
|
||||
t,
|
||||
strings.contains(text, "Use \"envr [command] --help\""),
|
||||
"missing help hint",
|
||||
)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_command_help_backup :: proc(t: ^testing.T) {
|
||||
text, ok := command_help_text("backup")
|
||||
testing.expect(t, ok, "command_help_text(\"backup\") returned false")
|
||||
|
||||
testing.expect(t, strings.contains(text, "Usage:"), "missing Usage line")
|
||||
testing.expect(
|
||||
t,
|
||||
strings.contains(text, "envr backup <path>"),
|
||||
"missing usage pattern",
|
||||
)
|
||||
testing.expect(
|
||||
t,
|
||||
strings.contains(text, "Aliases:"),
|
||||
"missing Aliases section",
|
||||
)
|
||||
testing.expect(t, strings.contains(text, "add"), "missing 'add' alias")
|
||||
testing.expect(t, strings.contains(text, "Flags:"), "missing Flags section")
|
||||
testing.expect(
|
||||
t,
|
||||
strings.contains(text, "--help"),
|
||||
"missing --help in flags",
|
||||
)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_command_help_add_alias :: proc(t: ^testing.T) {
|
||||
text, ok := command_help_text("add")
|
||||
testing.expect(t, ok, "command_help_text(\"add\") returned false")
|
||||
testing.expect(
|
||||
t,
|
||||
strings.contains(text, "envr backup <path>"),
|
||||
"'add' alias should resolve to backup usage",
|
||||
)
|
||||
testing.expect(t, strings.contains(text, "Aliases:"), "missing Aliases section")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_command_help_init_no_aliases :: proc(t: ^testing.T) {
|
||||
text, ok := command_help_text("init")
|
||||
testing.expect(t, ok, "command_help_text(\"init\") returned false")
|
||||
|
||||
testing.expect(t, strings.contains(text, "Usage:"), "missing Usage line")
|
||||
testing.expect(
|
||||
t,
|
||||
!strings.contains(text, "Aliases:"),
|
||||
"init should not have Aliases section",
|
||||
)
|
||||
testing.expect(t, strings.contains(text, "Flags:"), "missing Flags section")
|
||||
testing.expect(
|
||||
t,
|
||||
strings.contains(text, "help for init"),
|
||||
"missing 'help for init'",
|
||||
)
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_command_help_unknown :: proc(t: ^testing.T) {
|
||||
text, ok := command_help_text("nonexistent")
|
||||
testing.expect(t, !ok, "command_help_text(\"nonexistent\") should return false")
|
||||
testing.expect(t, len(text) == 0, "text should be empty for unknown command")
|
||||
}
|
||||
|
||||
@(test)
|
||||
test_command_help_version :: proc(t: ^testing.T) {
|
||||
text, ok := command_help_text("version")
|
||||
testing.expect(t, ok, "command_help_text(\"version\") returned false")
|
||||
testing.expect(t, strings.contains(text, "Usage:"), "missing Usage line")
|
||||
testing.expect(
|
||||
t,
|
||||
!strings.contains(text, "Aliases:"),
|
||||
"version should not have Aliases section",
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import "core:strings"
|
||||
|
||||
cmd_backup :: proc(cmd: ^Command) {
|
||||
if len(cmd.args) != 1 {
|
||||
fmt.println("Usage: envr backup <path>")
|
||||
print_command_help("backup")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import "core:strings"
|
||||
|
||||
cmd_remove :: proc(cmd: ^Command) {
|
||||
if len(cmd.args) != 1 {
|
||||
fmt.println("Usage: envr remove <path>")
|
||||
print_command_help("remove")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import "core:strings"
|
||||
|
||||
cmd_restore :: proc(cmd: ^Command) {
|
||||
if len(cmd.args) != 1 {
|
||||
fmt.println("Usage: envr restore <path>")
|
||||
print_command_help("restore")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user