diff --git a/TEST_PLAN.md b/TEST_PLAN.md index a59b3af..17b3852 100644 --- a/TEST_PLAN.md +++ b/TEST_PLAN.md @@ -2,22 +2,17 @@ ## Current State -- 101 tests, all passing -- Strong coverage: crypto (100%), ssh (90%), db CRUD + env_file + update_dir, config save/load + paths, scan, features, cant_scan, parse_args +- 104 tests, all passing +- Strong coverage: crypto, ssh, db CRUD + env_file + update_dir, config save/load + paths, scan, features, cant_scan, parse_args, `-c`/`--config-file` flag - Misleading test files: `cmd_check_test`, `cmd_list_test`, `cmd_nushell_completion_test` don't test their namesake procs - Biggest remaining gap: all `cmd_*` handlers untested -## Next: `load_config` / `save_config` path param + `-c`/`--config-file` flag -- Refactor `load_config(path: string = "")` and `save_config(cfg, force, path: string = "")` — empty string defaults to `~/.envr/config.json` -- Add `-c`/`--config-file` to `parse_args` (now testable) -- Wire through `main.odin` so commands receive the config path -- Unblocks command handler tests with fixture configs +## Command handler tests -## Command handlers (need DB + filesystem fixtures) +Stdout will be captured by redirecting `os.stdout` to a pipe. ### `cmd_version` (cmd_version.odin) - Test default output (prints VERSION) -- Capture stdout, assert content ### `cmd_list` (cmd_list.odin) - Test TTY path: fixture DB with rows, capture table output @@ -69,7 +64,6 @@ ## Notes -- All command handler tests will need stdout capture. Consider extracting a helper or using `io.Writer` injection. - DB integration tests should use in-memory SQLite (`:memory:`) where possible. - Temp dir fixtures should follow the pattern in `scan_test.odin`. - External dependency tests (`fd`, `git`) should use `#assert` to ensure the dependency is present rather than silently skipping (TODO 28). diff --git a/cli.odin b/cli.odin index c114a3d..e5efa2e 100644 --- a/cli.odin +++ b/cli.odin @@ -8,10 +8,11 @@ import "core:os" import "core:strings" Command :: struct { - name: string, - args: [dynamic]string, - flags: map[string]string, - bool_set: map[string]bool, + name: string, + args: [dynamic]string, + flags: map[string]string, + bool_set: map[string]bool, + config_path: string, } CommandInfo :: struct { @@ -94,6 +95,16 @@ parse_args :: proc(args: []string) -> (cmd: Command, ok: bool) { } } + if val, ok := cmd.flags["config-file"]; ok { + cmd.config_path = val + } else if val, ok := cmd.flags["c"]; ok { + cmd.config_path = val + } else { + // FIXME: Handle err + home, _ := os.user_home_dir(context.allocator) + cmd.config_path = default_config_path(home) + } + if has_flag(&cmd, "help") { print_command_help(cmd.name) return Command{}, false @@ -146,7 +157,7 @@ write_command_help :: proc(name: string, w: io.Writer) -> bool { fmt.wprintf(w, "\n%s\n", info.long, flush = false) } - fmt.wprintf(w, "\nFlags:\n -h, --help help for %s\n", info.name, flush = false) + fmt.wprintf(w, "\nFlags:\n -h, --help help for %s\n -c, --config-file config file (default \"~/.envr/config.json\")\n", info.name, flush = false) return true } @@ -227,6 +238,7 @@ Available Commands: ` Flags: -h, --help help for envr + -c, --config-file config file (default "~/.envr/config.json") Use "envr [command] --help" for more information about a command. `, diff --git a/cli_test.odin b/cli_test.odin index 29adb0f..ccff77f 100644 --- a/cli_test.odin +++ b/cli_test.odin @@ -315,3 +315,44 @@ test_parse_args_flag_then_positional_then_flag :: proc(t: ^testing.T) { testing.expect(t, cmd.args[0] == "a.env") } +@(test) +test_parse_args_config_file_long_flag :: proc(t: ^testing.T) { + cmd, ok := parse_args([]string{"envr", "list", "--config-file", "/custom/config.json"}) + testing.expect(t, ok, "should succeed") + if !ok do return + defer delete(cmd.args) + defer delete(cmd.flags) + defer delete(cmd.bool_set) + + testing.expect(t, cmd.config_path == "/custom/config.json", "config_path should be set from --config-file") +} + +@(test) +test_parse_args_config_file_short_flag :: proc(t: ^testing.T) { + cmd, ok := parse_args([]string{"envr", "list", "-c", "/custom/config.json"}) + testing.expect(t, ok, "should succeed") + if !ok do return + defer delete(cmd.args) + defer delete(cmd.flags) + defer delete(cmd.bool_set) + + testing.expect(t, cmd.config_path == "/custom/config.json", "config_path should be set from -c") +} + +@(test) +test_parse_args_config_file_defaults :: proc(t: ^testing.T) { + cmd, ok := parse_args([]string{"envr", "list"}) + testing.expect(t, ok, "should succeed") + if !ok do return + defer delete(cmd.args) + defer delete(cmd.flags) + defer delete(cmd.bool_set) + + testing.expect(t, len(cmd.config_path) > 0, "config_path should default to non-empty path") + testing.expect( + t, + strings.contains(cmd.config_path, ".envr"), + "default config_path should contain .envr dir, got %s", + ) +} + diff --git a/cmd_backup.odin b/cmd_backup.odin index b55e12f..bcc0485 100644 --- a/cmd_backup.odin +++ b/cmd_backup.odin @@ -21,7 +21,7 @@ cmd_backup :: proc(cmd: ^Command) { return } - db, db_ok := db_open() + db, db_ok := db_open(cmd.config_path) if !db_ok { // TODO: log a message return diff --git a/cmd_check.odin b/cmd_check.odin index e0028d0..f373a50 100644 --- a/cmd_check.odin +++ b/cmd_check.odin @@ -31,7 +31,7 @@ cmd_check :: proc(cmd: ^Command) { abs_path = resolved } - db, db_ok := db_open() + db, db_ok := db_open(cmd.config_path) if !db_ok { return } diff --git a/cmd_edit_config.odin b/cmd_edit_config.odin index 7afbbdc..948a3ce 100644 --- a/cmd_edit_config.odin +++ b/cmd_edit_config.odin @@ -2,7 +2,6 @@ package main import "core:fmt" import "core:os" -import "core:path/filepath" cmd_edit_config :: proc(cmd: ^Command) { editor := os.get_env("EDITOR", context.allocator) @@ -11,11 +10,7 @@ cmd_edit_config :: proc(cmd: ^Command) { return } - config_path, join_err := filepath.join([]string{envr_dir(), "config.json"}) - if join_err != nil { - fmt.printf("Error building config path: %v\n", join_err) - return - } + config_path := cmd.config_path _, stat_err := os.stat(config_path, context.allocator) if stat_err != nil { diff --git a/cmd_init.odin b/cmd_init.odin index cc8d017..2eb581c 100644 --- a/cmd_init.odin +++ b/cmd_init.odin @@ -5,7 +5,7 @@ import "core:fmt" cmd_init :: proc(cmd: ^Command) { force := has_flag(cmd, "force") || has_flag(cmd, "f") - _, cfg_exists := load_config() + _, cfg_exists := load_config(cmd.config_path) if cfg_exists && !force { fmt.println("You have already initialized envr.") fmt.println("Run again with the --force flag if you want to reinitialize.") @@ -41,7 +41,7 @@ cmd_init :: proc(cmd: ^Command) { return } - cfg := new_config(selected_paths[:]) + cfg := new_config(selected_paths[:], cmd.config_path) if !save_config(cfg, force = force) { return } diff --git a/cmd_list.odin b/cmd_list.odin index dacf1e0..9f9e771 100644 --- a/cmd_list.odin +++ b/cmd_list.odin @@ -14,7 +14,7 @@ ListEntry :: struct { } cmd_list :: proc(cmd: ^Command) { - db, db_ok := db_open() + db, db_ok := db_open(cmd.config_path) if !db_ok { return } diff --git a/cmd_remove.odin b/cmd_remove.odin index febbe76..21eeead 100644 --- a/cmd_remove.odin +++ b/cmd_remove.odin @@ -28,7 +28,7 @@ cmd_remove :: proc(cmd: ^Command) { abs_path = resolved } - db, db_ok := db_open() + db, db_ok := db_open(cmd.config_path) if !db_ok { return } diff --git a/cmd_restore.odin b/cmd_restore.odin index 9e27400..1d8f44f 100644 --- a/cmd_restore.odin +++ b/cmd_restore.odin @@ -29,7 +29,7 @@ cmd_restore :: proc(cmd: ^Command) { abs_path = resolved } - db, db_ok := db_open() + db, db_ok := db_open(cmd.config_path) if !db_ok { return } diff --git a/cmd_scan.odin b/cmd_scan.odin index 5d94fef..99510c5 100644 --- a/cmd_scan.odin +++ b/cmd_scan.odin @@ -14,7 +14,7 @@ cmd_scan :: proc(cmd: ^Command) { return } - db, db_ok := db_open() + db, db_ok := db_open(cmd.config_path) if !db_ok { return } diff --git a/cmd_sync.odin b/cmd_sync.odin index 5d73bc5..d589a43 100644 --- a/cmd_sync.odin +++ b/cmd_sync.odin @@ -14,7 +14,7 @@ SyncEntry :: struct { // TODO: Check for quiet failures. cmd_sync :: proc(cmd: ^Command) { - db, db_ok := db_open() + db, db_ok := db_open(cmd.config_path) if !db_ok { return } diff --git a/config.odin b/config.odin index 154dc1e..77c1e4b 100644 --- a/config.odin +++ b/config.odin @@ -18,21 +18,18 @@ ScanConfig :: struct { } Config :: struct { - Keys: [dynamic]SshKeyPair `json:"keys"`, - ScanConfig: ScanConfig `json:"scan"`, + Keys: [dynamic]SshKeyPair `json:"keys"`, + ScanConfig: ScanConfig `json:"scan"`, + config_path: string `json:"-"`, } -load_config :: proc() -> (Config, bool) { - home, home_err := os.user_home_dir(context.temp_allocator) - if home_err != nil { - fmt.printf("Error getting home dir: %v\n", home_err) - return Config{}, false - } - config_path, join_err := filepath.join([]string{home, ".envr", "config.json"}) - if join_err != nil { - return Config{}, false - } +default_config_path :: proc(home: string) -> string { + // FIXME: catch error + path, _ := filepath.join([]string{home, ".envr", "config.json"}) + return path +} +load_config :: proc(config_path: string) -> (Config, bool) { data, read_err := os.read_entire_file_from_path(config_path, context.allocator) if read_err != nil { fmt.println("No config file found. Please run `envr init` to generate one.") @@ -45,6 +42,7 @@ load_config :: proc() -> (Config, bool) { fmt.printf("Error parsing config: %v\n", err) return Config{}, false } + cfg.config_path = config_path return cfg, true } @@ -55,15 +53,12 @@ delete_config :: proc(cfg: Config) { delete(cfg.ScanConfig.Include) } -envr_dir :: proc() -> string { - home, _ := os.user_home_dir(context.allocator) - dir, _ := filepath.join([]string{home, ".envr"}) - return dir +envr_dir :: proc(config_path: string) -> string { + return filepath.dir(config_path) } -data_encrypted_path :: proc() -> string { - dir := envr_dir() - path, _ := filepath.join([]string{dir, "data.envr"}) +data_encrypted_path :: proc(config_path: string) -> string { + path, _ := filepath.join([]string{envr_dir(config_path), "data.envr"}) return path } @@ -113,7 +108,10 @@ find_ssh_private_keys :: proc() -> (keys: [dynamic]string, ok: bool) { return } -new_config :: proc(private_key_paths: []string) -> Config { +new_config :: proc( + private_key_paths: []string, + cfg_path: string = "~/.envr/config.json", +) -> Config { keys := make([dynamic]SshKeyPair, 0, len(private_key_paths)) for priv in private_key_paths { // TODO: Is this bad? @@ -136,30 +134,22 @@ new_config :: proc(private_key_paths: []string) -> Config { Include = include, } - return Config{Keys = keys, ScanConfig = scan_cfg} + return Config{Keys = keys, ScanConfig = scan_cfg, config_path = cfg_path} } save_config :: proc(cfg: Config, force: bool = false) -> bool { - home, home_err := os.user_home_dir(context.allocator) - if home_err != nil { - fmt.printf("Error getting home dir: %v\n", home_err) - return false - } - - config_dir, _ := filepath.join([]string{home, ".envr"}) + config_dir := envr_dir(cfg.config_path) if !os.exists(config_dir) { mkdir_err := os.make_directory(config_dir) if mkdir_err != nil { - fmt.printf("Error creating ~/.envr directory: %v\n", mkdir_err) + fmt.printf("Error creating %s directory: %v\n", config_dir, mkdir_err) return false } } - config_path, _ := filepath.join([]string{config_dir, "config.json"}) - - if os.exists(config_path) && !force { - info, stat_err := os.stat(config_path, context.allocator) + if os.exists(cfg.config_path) && !force { + info, stat_err := os.stat(cfg.config_path, context.allocator) if stat_err == nil { defer os.file_info_delete(info, context.allocator) if info.size > 0 { @@ -175,7 +165,7 @@ save_config :: proc(cfg: Config, force: bool = false) -> bool { return false } - write_err := os.write_entire_file(config_path, data) + write_err := os.write_entire_file(cfg.config_path, data) if write_err != nil { fmt.printf("Error writing config: %v\n", write_err) return false diff --git a/config_test.odin b/config_test.odin index 3f9cf8c..60086ec 100644 --- a/config_test.odin +++ b/config_test.odin @@ -2,6 +2,7 @@ package main import "core:fmt" import "core:os" +import "core:path/filepath" import "core:strings" import "core:sync" import "core:testing" @@ -69,27 +70,19 @@ test_new_config_exclude_patterns :: proc(t: ^testing.T) { @(test) test_save_load_config_roundtrip :: proc(t: ^testing.T) { - sync.mutex_lock(&home_mutex) - defer sync.mutex_unlock(&home_mutex) - - old_home := os.get_env("HOME", context.temp_allocator) - defer { - if old_home != "" { - os.set_env("HOME", old_home) - } - } - base := fmt.tprintf("/tmp/envr-test-cfg-rt-%d", os.get_pid()) os.mkdir_all(base) defer os.remove_all(base) - os.set_env("HOME", base) - cfg := new_config([]string{"/home/user/.ssh/id_ed25519"}) + cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator) + testing.expect(t, err == nil, "cfgPath should build successfully") + + cfg := new_config([]string{"/home/user/.ssh/id_ed25519"}, cfgPath) defer delete_config(cfg) - testing.expect(t, save_config(cfg, force=true), "save should succeed") + testing.expect(t, save_config(cfg, force = true), "save should succeed") - loaded, ok := load_config() + loaded, ok := load_config(cfg.config_path) testing.expect(t, ok, "load should succeed") if !ok do return defer delete_config(loaded) @@ -105,106 +98,62 @@ test_save_load_config_roundtrip :: proc(t: ^testing.T) { @(test) test_load_config_missing :: proc(t: ^testing.T) { - sync.mutex_lock(&home_mutex) - defer sync.mutex_unlock(&home_mutex) - - old_home := os.get_env("HOME", context.temp_allocator) - defer { - if old_home != "" { - os.set_env("HOME", old_home) - } - } - - base := fmt.tprintf("/tmp/envr-test-cfg-missing-%d", os.get_pid()) - os.mkdir_all(base) - defer os.remove_all(base) - os.set_env("HOME", base) - - _, ok := load_config() + _, ok := load_config("/tmp/envr-test-cfg-nonexistent/config.json") testing.expect(t, !ok, "missing config should return false") } @(test) test_save_config_no_clobber :: proc(t: ^testing.T) { - sync.mutex_lock(&home_mutex) - defer sync.mutex_unlock(&home_mutex) - - old_home := os.get_env("HOME", context.temp_allocator) - defer { - if old_home != "" { - os.set_env("HOME", old_home) - } - } - base := fmt.tprintf("/tmp/envr-test-cfg-noclobber-%d", os.get_pid()) os.mkdir_all(base) defer os.remove_all(base) - os.set_env("HOME", base) - cfg := new_config([]string{"/home/user/.ssh/key1"}) + cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator) + testing.expect(t, err == nil, "cfgPath should build successfully") + + cfg := new_config([]string{"/home/user/.ssh/key1"}, cfgPath) defer delete_config(cfg) - testing.expect(t, save_config(cfg, force=true), "first save should succeed") + testing.expect(t, save_config(cfg, force = true), "first save should succeed") - cfg2 := new_config([]string{"/home/user/.ssh/key2"}) + cfg2 := new_config([]string{"/home/user/.ssh/key2"}, cfgPath) defer delete_config(cfg2) testing.expect(t, !save_config(cfg2), "second save without force should fail") } @(test) test_save_config_force_overwrites :: proc(t: ^testing.T) { - sync.mutex_lock(&home_mutex) - defer sync.mutex_unlock(&home_mutex) - - old_home := os.get_env("HOME", context.temp_allocator) - defer { - if old_home != "" { - os.set_env("HOME", old_home) - } - } - base := fmt.tprintf("/tmp/envr-test-cfg-force-%d", os.get_pid()) os.mkdir_all(base) defer os.remove_all(base) - os.set_env("HOME", base) - cfg := new_config([]string{"/home/user/.ssh/key1"}) + cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator) + testing.expect(t, err == nil, "cfgPath should build successfully") + + cfg := new_config([]string{"/home/user/.ssh/key1"}, cfgPath) defer delete_config(cfg) - testing.expect(t, save_config(cfg, force=true), "first save should succeed") + testing.expect(t, save_config(cfg, force = true), "first save should succeed") - cfg2 := new_config([]string{"/home/user/.ssh/key2"}) + cfg2 := new_config([]string{"/home/user/.ssh/key2"}, cfgPath) defer delete_config(cfg2) - testing.expect(t, save_config(cfg2, force=true), "force save should overwrite") + testing.expect(t, save_config(cfg2, force = true), "force save should overwrite") - loaded, ok := load_config() + loaded, ok := load_config(cfgPath) testing.expect(t, ok, "load should succeed") if !ok do return defer delete_config(loaded) testing.expect(t, len(loaded.Keys) == 1, "should have 1 key") - testing.expect(t, loaded.Keys[0].Private == "/home/user/.ssh/key2", "should be the overwritten key") + testing.expect( + t, + loaded.Keys[0].Private == "/home/user/.ssh/key2", + "should be the overwritten key", + ) } @(test) test_envr_dir :: proc(t: ^testing.T) { - sync.mutex_lock(&home_mutex) - defer sync.mutex_unlock(&home_mutex) - - old_home := os.get_env("HOME", context.temp_allocator) - defer { - if old_home != "" { - os.set_env("HOME", old_home) - } - } - - os.set_env("HOME", "/tmp/envr-fake-home-envrdir") - - dir := envr_dir() - testing.expectf( - t, - strings.has_suffix(dir, ".envr"), - "dir should end with .envr, got %s", - dir, - ) + dir := envr_dir("/tmp/envr-fake-home-envrdir/.envr/config.json") + testing.expectf(t, strings.has_suffix(dir, ".envr"), "dir should end with .envr, got %s", dir) testing.expectf( t, strings.contains(dir, "envr-fake-home-envrdir"), @@ -215,19 +164,7 @@ test_envr_dir :: proc(t: ^testing.T) { @(test) test_data_encrypted_path :: proc(t: ^testing.T) { - sync.mutex_lock(&home_mutex) - defer sync.mutex_unlock(&home_mutex) - - old_home := os.get_env("HOME", context.temp_allocator) - defer { - if old_home != "" { - os.set_env("HOME", old_home) - } - } - - os.set_env("HOME", "/tmp/envr-fake-home-datapath") - - p := data_encrypted_path() + p := data_encrypted_path("/tmp/envr-fake-home-datapath/config.json") testing.expectf(t, strings.has_suffix(p, "data.envr"), "should end with data.envr, got %s", p) testing.expectf(t, strings.contains(p, ".envr"), "should contain .envr dir, got %s", p) } @@ -247,9 +184,7 @@ test_search_paths_expands_tilde :: proc(t: ^testing.T) { os.set_env("HOME", "/tmp/envr-fake-home-search") cfg := Config { - ScanConfig = ScanConfig { - Include = make([dynamic]string, 0, 1), - }, + ScanConfig = ScanConfig{Include = make([dynamic]string, 0, 1)}, } defer delete(cfg.ScanConfig.Include) append(&cfg.ScanConfig.Include, "~") diff --git a/db.odin b/db.odin index 24972f6..c15daf6 100644 --- a/db.odin +++ b/db.odin @@ -48,13 +48,13 @@ make_temp_path :: proc() -> string { return strings.to_string(b) } -db_open :: proc() -> (Db, bool) { - cfg, ok := load_config() +db_open :: proc(cfg_path: string) -> (Db, bool) { + cfg, ok := load_config(cfg_path) if !ok { return Db{}, false } - data_path := data_encrypted_path() + data_path := data_encrypted_path(cfg.config_path) _, stat_err := os.stat(data_path, context.allocator) db: ^rawptr @@ -108,8 +108,8 @@ db_close :: proc(d: ^Db) { return } - data_path := data_encrypted_path() - envr_d := envr_dir() + data_path := data_encrypted_path(d.cfg.config_path) + envr_d := envr_dir(d.cfg.config_path) os.mkdir_all(envr_d) write_err := os.write_entire_file(data_path, encrypted) @@ -186,7 +186,7 @@ db_vacuum_to_file :: proc(db: ^rawptr, path: string) -> bool { } db_restore_from_encrypted :: proc(db: ^rawptr, cfg: Config) -> bool { - data_path := data_encrypted_path() + data_path := data_encrypted_path(cfg.config_path) encrypted_data, read_err := os.read_entire_file_from_path(data_path, context.temp_allocator) if read_err != nil { fmt.printf("Error reading encrypted database: %v\n", read_err)