feat: Config can be loaded from any path with --config-file (-c) flag.

This commit is contained in:
2026-06-15 08:18:34 -04:00
parent e23ea960d7
commit 4a26ee8145
15 changed files with 133 additions and 166 deletions

View File

@@ -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).

View File

@@ -12,6 +12,7 @@ Command :: struct {
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 <path> 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 <path> config file (default "~/.envr/config.json")
Use "envr [command] --help" for more information about a command.
`,

View File

@@ -315,3 +315,44 @@ test_parse_args_flag_then_positional_then_flag :: proc(t: ^testing.T) {
}
@(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",
)
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -20,19 +20,16 @@ ScanConfig :: struct {
Config :: struct {
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

View File

@@ -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")
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")
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")
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")
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, "~")

12
db.odin
View File

@@ -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)