From d890c88b6deffff52b6a3f16f885a39de24eb9f5 Mon Sep 17 00:00:00 2001 From: Spencer Brower Date: Thu, 11 Jun 2026 21:27:20 -0400 Subject: [PATCH] refactor(odin): port check command. --- TODOS.md | 2 + cli.odin | 1 + cmd_check.odin | 79 +++++++++++++++++++++++++ cmd_check_test.odin | 43 ++++++++++++++ main.odin | 2 + scan.odin | 139 ++++++++++++++++++++++++++++++++++++++++++++ scan_test.odin | 94 ++++++++++++++++++++++++++++++ stubs.odin | 4 -- 8 files changed, 360 insertions(+), 4 deletions(-) create mode 100644 cmd_check.odin create mode 100644 cmd_check_test.odin create mode 100644 scan.odin create mode 100644 scan_test.odin diff --git a/TODOS.md b/TODOS.md index 56576e8..66dcbfe 100644 --- a/TODOS.md +++ b/TODOS.md @@ -47,3 +47,5 @@ Note: These todos can wait until all the subcommands have been ported. ## 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"`. + +21. Check for prealloc opportunities. i.e. `make([dynamic]string)` -> `make([dynamic]string, 5)`. diff --git a/cli.odin b/cli.odin index f0ebe3c..167b7e2 100644 --- a/cli.odin +++ b/cli.odin @@ -44,6 +44,7 @@ IMPLEMENTED_COMMANDS := []string{ "remove", "restore", "edit-config", + "check", } parse_args :: proc() -> (cmd: Command, ok: bool) { diff --git a/cmd_check.odin b/cmd_check.odin new file mode 100644 index 0000000..30311c9 --- /dev/null +++ b/cmd_check.odin @@ -0,0 +1,79 @@ +package main + +import "core:fmt" +import "core:os" +import "core:path/filepath" +import "core:strings" + +cmd_check :: proc(cmd: ^Command) { + check_path: string + if len(cmd.args) > 0 { + check_path = cmd.args[0] + } else { + cwd, cwd_err := os.get_working_directory(context.allocator) + if cwd_err != nil { + fmt.printf("Error getting current directory: %v\n", cwd_err) + return + } + check_path = cwd + } + + abs_path: string + if filepath.is_abs(check_path) { + abs_path = check_path + } else { + resolved, abs_err := filepath.abs(check_path) + if abs_err != nil { + fmt.printf("Error getting absolute path: %v\n", abs_err) + return + } + abs_path = resolved + } + + db, db_ok := db_open() + if !db_ok { + return + } + defer db_close(&db) + + is_dir := os.is_directory(abs_path) + + files_in_path: [dynamic]string + + if is_dir { + if !can_scan() { + fmt.println("Error: please install fd to use the check command (https://github.com/sharkdp/fd)") + return + } + + scanned, scan_ok := scan_path(abs_path, db.cfg) + if !scan_ok { + fmt.println("Error scanning directory for .env files") + return + } + files_in_path = scanned + } else { + append(&files_in_path, abs_path) + } + + db_files, list_ok := db_list(&db) + if !list_ok { + return + } + + not_backed := find_unbacked(files_in_path[:], db_files[:]) + + if len(not_backed) == 0 { + if len(files_in_path) == 0 { + fmt.println("No .env files found in the specified directory.") + } else { + fmt.println("✓ All .env files in the directory are backed up.") + } + } else { + fmt.printf("Found %d .env file(s) that are not backed up:\n", len(not_backed)) + for file in not_backed { + fmt.printf(" %s\n", file) + } + fmt.println("\nRun 'envr sync' to back up these files.") + } +} diff --git a/cmd_check_test.odin b/cmd_check_test.odin new file mode 100644 index 0000000..10b7a3c --- /dev/null +++ b/cmd_check_test.odin @@ -0,0 +1,43 @@ +package main + +import "core:fmt" +import "core:testing" + +@(test) +test_find_unbacked_finds_missing :: proc(t: ^testing.T) { + local := []string{"/a/.env", "/b/.env", "/c/.env"} + db := []EnvFile{{Path = "/a/.env"}, {Path = "/b/.env"}} + + result := find_unbacked(local, db[:]) + testing.expect(t, len(result) == 1, fmt.aprintf("expected 1 unbacked, got %d", len(result))) + if len(result) > 0 { + testing.expect(t, result[0] == "/c/.env", fmt.aprintf("expected /c/.env, got %s", result[0])) + } +} + +@(test) +test_find_unbacked_all_backed :: proc(t: ^testing.T) { + local := []string{"/a/.env", "/b/.env"} + db := []EnvFile{{Path = "/a/.env"}, {Path = "/b/.env"}} + + result := find_unbacked(local, db[:]) + testing.expect(t, len(result) == 0, fmt.aprintf("expected 0 unbacked, got %d", len(result))) +} + +@(test) +test_find_unbacked_no_local :: proc(t: ^testing.T) { + local: []string + db := []EnvFile{{Path = "/a/.env"}} + + result := find_unbacked(local, db[:]) + testing.expect(t, len(result) == 0, fmt.aprintf("expected 0 unbacked, got %d", len(result))) +} + +@(test) +test_find_unbacked_none_backed :: proc(t: ^testing.T) { + local := []string{"/a/.env", "/b/.env"} + db: []EnvFile + + result := find_unbacked(local, db[:]) + testing.expect(t, len(result) == 2, fmt.aprintf("expected 2 unbacked, got %d", len(result))) +} diff --git a/main.odin b/main.odin index c1f5795..2a005ba 100644 --- a/main.odin +++ b/main.odin @@ -31,6 +31,8 @@ main :: proc() { cmd_restore(&cmd) case "edit-config": cmd_edit_config(&cmd) + case "check": + cmd_check(&cmd) case: fmt.printf("Unknown command: %s\n", cmd.name) print_usage() diff --git a/scan.odin b/scan.odin new file mode 100644 index 0000000..ab8e556 --- /dev/null +++ b/scan.odin @@ -0,0 +1,139 @@ +package main + +import "core:fmt" +import "core:os" +import "core:path/filepath" +import "core:strings" +import "core:sync" + +fd_counter: sync.Atomic_Mutex +fd_seq: int + +next_fd_tmp_path :: proc() -> string { + sync.atomic_mutex_lock(&fd_counter) + n := fd_seq + fd_seq += 1 + sync.atomic_mutex_unlock(&fd_counter) + return fmt.aprintf("/tmp/envr-fd-%d-%d", os.get_pid(), n) +} + +build_fd_args :: proc(search_path: string, cfg: Config, include_ignored: bool) -> []string { + args := make([dynamic]string, 0, 3 + 2 * len(cfg.ScanConfig.Exclude) + 2) + append(&args, "fd") + append(&args, "-a") + append(&args, cfg.ScanConfig.Matcher) + + for exclude in cfg.ScanConfig.Exclude { + append(&args, "-E") + append(&args, exclude) + } + + if include_ignored { + append(&args, "-HI") + } else { + append(&args, "-H") + } + + append(&args, search_path) + return args[:] +} + +run_fd :: proc(args: []string) -> (lines: [dynamic]string, ok: bool) { + tmp_path := next_fd_tmp_path() + tmp_file, tmp_err := os.open(tmp_path, os.O_CREATE | os.O_WRONLY | os.O_TRUNC) + if tmp_err != nil { + return + } + + desc := os.Process_Desc { + command = args, + stdout = tmp_file, + stderr = nil, + } + + p, start_err := os.process_start(desc) + os.close(tmp_file) + if start_err != nil { + os.remove(tmp_path) + return + } + + state, wait_err := os.process_wait(p) + if wait_err != nil || state.exit_code != 0 { + os.remove(tmp_path) + return + } + + data, read_err := os.read_entire_file_from_path(tmp_path, context.allocator) + os.remove(tmp_path) + if read_err != nil { + return + } + + output := string(data) + output = strings.trim_space(output) + if len(output) == 0 { + ok = true + return + } + + raw_lines := strings.split(output, "\n") + for line in raw_lines { + trimmed, _ := strings.clone(strings.trim_space(line)) + if len(trimmed) > 0 { + append(&lines, trimmed) + } + } + + ok = true + return +} + +scan_path :: proc(search_path: string, cfg: Config) -> (paths: [dynamic]string, ok: bool) { + all_args := build_fd_args(search_path, cfg, true) + all_files, all_ok := run_fd(all_args) + if !all_ok { + return + } + + unignored_args := build_fd_args(search_path, cfg, false) + unignored_files, unignored_ok := run_fd(unignored_args) + if !unignored_ok { + return + } + + unignored_set: map[string]bool + for file in unignored_files { + unignored_set[file] = true + } + + for file in all_files { + if !(file in unignored_set) { + append(&paths, file) + } + } + + ok = true + return +} + +can_scan :: proc() -> bool { + feats := check_features() + return has_feature(feats, .Fd) +} + +find_unbacked :: proc(local_files: []string, db_files: []EnvFile) -> [dynamic]string { + backed_set: map[string]bool + for file in db_files { + backed_set[file.Path] = true + } + + unbacked: [dynamic]string + for file in local_files { + if !(file in backed_set) { + append(&unbacked, file) + } + } + return unbacked +} + diff --git a/scan_test.odin b/scan_test.odin new file mode 100644 index 0000000..f10da69 --- /dev/null +++ b/scan_test.odin @@ -0,0 +1,94 @@ +package main + +import "core:fmt" +import "core:os" +import "core:path/filepath" +import "core:testing" + +@(test) +test_scan_path_finds_gitignored_env_files :: proc(t: ^testing.T) { + if !can_scan() { + return + } + + base := fmt.aprintf("/tmp/envr-scan-test-%d", os.get_pid()) + os.mkdir_all(base) + defer os.remove_all(base) + + git_init := os.Process_Desc{ + command = []string{"git", "init"}, + working_dir = base, + stdout = os.stderr, + stderr = os.stderr, + } + p, err := os.process_start(git_init) + if err != nil { + return + } + _, wait_err := os.process_wait(p) + if wait_err != nil { + return + } + + gitignore_path := fmt.aprintf("%s/.gitignore", base) + _ = os.write_entire_file(gitignore_path, ".env*\n") + + _ = os.write_entire_file(fmt.aprintf("%s/.env", base), "SECRET=1") + _ = os.write_entire_file(fmt.aprintf("%s/.env.testing", base), "TEST=1") + _ = os.write_entire_file(fmt.aprintf("%s/config.yaml", base), "key: value") + + cfg := Config{ + ScanConfig = ScanConfig{ + Matcher = "\\.env", + Exclude = []string{}, + Include = []string{}, + }, + } + + results, ok := scan_path(base, cfg) + testing.expect(t, ok, "scan_path should succeed") + + found_env := false + found_testing := false + found_config := false + + for path in results { + _, filename := filepath.split(path) + if filename == ".env" { + found_env = true + } + if filename == ".env.testing" { + found_testing = true + } + if filename == "config.yaml" { + found_config = true + } + } + + testing.expect(t, found_env, "should find .env (gitignored)") + testing.expect(t, found_testing, "should find .env.testing (gitignored)") + testing.expect(t, !found_config, "should NOT find config.yaml (not gitignored)") +} + +@(test) +test_scan_path_empty_dir :: proc(t: ^testing.T) { + if !can_scan() { + return + } + + base := fmt.aprintf("/tmp/envr-scan-empty-%d", os.get_pid()) + os.mkdir_all(base) + defer os.remove_all(base) + + cfg := Config{ + ScanConfig = ScanConfig{ + Matcher = "\\.env", + Exclude = []string{}, + Include = []string{}, + }, + } + + results, ok := scan_path(base, cfg) + testing.expect(t, ok, "scan_path should succeed") + testing.expect(t, len(results) == 0, fmt.aprintf("expected 0 results, got %d", len(results))) +} diff --git a/stubs.odin b/stubs.odin index d291573..cbfc94b 100644 --- a/stubs.odin +++ b/stubs.odin @@ -13,7 +13,3 @@ cmd_scan :: proc(cmd: ^Command) { cmd_sync :: proc(cmd: ^Command) { fmt.println("TODO: sync") } - -cmd_check :: proc(cmd: ^Command) { - fmt.println("TODO: check") -}