From 0a332adfdfc0824f2112d66e9746644e2c274ad0 Mon Sep 17 00:00:00 2001 From: Spencer Brower Date: Fri, 12 Jun 2026 08:28:58 -0400 Subject: [PATCH] feat(odin): Ported scan command. --- TODOS.md | 10 +++ cli.odin | 1 + cmd_scan.odin | 90 +++++++++++++++++++++++ config.odin | 24 ++++++- main.odin | 2 + prompt.odin | 193 ++++++++++++++++++++++++++++++++++++++++++++++++++ scan.odin | 6 ++ stubs.odin | 4 -- tty.odin | 1 + 9 files changed, 325 insertions(+), 6 deletions(-) create mode 100644 cmd_scan.odin create mode 100644 prompt.odin diff --git a/TODOS.md b/TODOS.md index 66dcbfe..22c1b5b 100644 --- a/TODOS.md +++ b/TODOS.md @@ -49,3 +49,13 @@ Note: These todos can wait until all the subcommands have been ported. 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)`. + +22. Replace is_tty with terminal.is_terminal + +23. Add a text filter to the multi_select. + +24. Create backup / fallback fd. + +25. Add tests for untested commands. + +26. Add a global --config -c flag to use an alternate config. diff --git a/cli.odin b/cli.odin index 167b7e2..cdde843 100644 --- a/cli.odin +++ b/cli.odin @@ -45,6 +45,7 @@ IMPLEMENTED_COMMANDS := []string{ "restore", "edit-config", "check", + "scan", } parse_args :: proc() -> (cmd: Command, ok: bool) { diff --git a/cmd_scan.odin b/cmd_scan.odin new file mode 100644 index 0000000..02dd56c --- /dev/null +++ b/cmd_scan.odin @@ -0,0 +1,90 @@ +package main + +import "core:encoding/json" +import "core:fmt" + +cmd_scan :: proc(cmd: ^Command) { + if !can_scan() { + fmt.println( + "Error: please install fd to use the scan command (https://github.com/sharkdp/fd)", + ) + return + } + + db, db_ok := db_open() + if !db_ok { + return + } + defer db_close(&db) + + search_dirs := search_paths(db.cfg) + if len(search_dirs) == 0 { + fmt.println("No search paths configured. Please run `envr init` or edit your config.") + return + } + + // TODO: Figure out a sane default + all_files: [dynamic]string + for dir in search_dirs { + found, scan_ok := scan_path(dir, db.cfg) + if !scan_ok { + fmt.printf("Error scanning %s\n", dir) + continue + } + for f in found { + append(&all_files, f) + } + } + + db_files, list_ok := db_list(&db) + if !list_ok { + return + } + + files := find_unbacked(all_files[:], db_files[:]) + + if len(files) == 0 { + fmt.println("No .env files found to add.") + return + } + + if !is_tty() { + output, marshal_err := json.marshal(files[:]) + if marshal_err != nil { + fmt.printf("Error marshaling files to JSON: %v\n", marshal_err) + return + } + fmt.println(string(output)) + return + } + + selected, result := multi_select("Select .env files to backup:", files[:]) + if result == .Cancel { + fmt.println("\x1b[2mCancelled.\x1b[0m") + return + } + + added_count: int + for i in 0 ..< len(files) { + if !selected[i] { + continue + } + env_file, ok := new_env_file(files[i]) + if !ok { + fmt.printf("Error reading %s\n", files[i]) + continue + } + if !db_insert(&db, env_file) { + fmt.printf("Error adding %s\n", files[i]) + continue + } + added_count += 1 + } + + if added_count > 0 { + fmt.printf("\x1b[1;32mSuccessfully added %d file(s) to backup.\x1b[0m\n", added_count) + } else { + fmt.println("\x1b[2mNo files were added.\x1b[0m") + } +} + diff --git a/config.odin b/config.odin index ece3c68..53933f3 100644 --- a/config.odin +++ b/config.odin @@ -4,6 +4,7 @@ import "core:encoding/json" import "core:fmt" import "core:os" import "core:path/filepath" +import "core:strings" SshKeyPair :: struct { Private: string `json:"private"`, @@ -11,14 +12,14 @@ SshKeyPair :: struct { } ScanConfig :: struct { - Matcher: string `json:"matcher"`, + Matcher: string `json:"matcher"`, Exclude: []string `json:"exclude"`, Include: []string `json:"include"`, } Config :: struct { Keys: []SshKeyPair `json:"keys"`, - ScanConfig: ScanConfig `json:"scan"`, + ScanConfig: ScanConfig `json:"scan"`, } load_config :: proc() -> (Config, bool) { @@ -59,3 +60,22 @@ data_age_path :: proc() -> string { path, _ := filepath.join([]string{dir, "data.age"}) return path } + +search_paths :: proc(cfg: Config) -> (paths: [dynamic]string) { + home, _ := os.user_home_dir(context.allocator) + + for include in cfg.ScanConfig.Include { + expanded, _ := strings.replace(include, "~", home, 1) + cloned, _ := strings.clone(expanded) + if filepath.is_abs(cloned) { + append(&paths, cloned) + } else { + resolved, err := filepath.abs(cloned) + if err == nil { + append(&paths, resolved) + } + } + } + return +} + diff --git a/main.odin b/main.odin index 2a005ba..233c9dd 100644 --- a/main.odin +++ b/main.odin @@ -33,6 +33,8 @@ main :: proc() { cmd_edit_config(&cmd) case "check": cmd_check(&cmd) + case "scan": + cmd_scan(&cmd) case: fmt.printf("Unknown command: %s\n", cmd.name) print_usage() diff --git a/prompt.odin b/prompt.odin new file mode 100644 index 0000000..4bce000 --- /dev/null +++ b/prompt.odin @@ -0,0 +1,193 @@ +package main + +import "core:fmt" +import "core:sys/posix" + +Raw_State :: struct { + original: posix.termios, + fd: posix.FD, +} + +enable_raw_mode :: proc(fd: posix.FD) -> (Raw_State, bool) { + state: Raw_State + state.fd = fd + + if posix.tcgetattr(fd, &state.original) != .OK { + return state, false + } + + attr: posix.termios = state.original + attr.c_lflag -= {.ICANON, .ECHO, .ISIG, .IEXTEN} + attr.c_iflag -= {.IXON, .ICRNL, .BRKINT, .INPCK, .ISTRIP} + attr.c_oflag -= {.OPOST} + attr.c_cflag += {.CS8} + attr.c_cc[.VMIN] = 1 + attr.c_cc[.VTIME] = 0 + + if posix.tcsetattr(fd, .TCSAFLUSH, &attr) != .OK { + return state, false + } + + return state, true +} + +disable_raw_mode :: proc(state: ^Raw_State) { + posix.tcsetattr(state.fd, .TCSAFLUSH, &state.original) +} + +Key :: enum { + Up, + Down, + Space, + Enter, + Escape, + Unknown, +} + +read_key :: proc() -> Key { + buf: [3]u8 + + n := posix.read(posix.STDIN_FILENO, &buf[0], 1) + if n <= 0 { + return .Unknown + } + + switch buf[0] { + case ' ': + return .Space + case '\n', '\r': + return .Enter + case 0x03: + return .Escape + case 0x1b: + tv: posix.timeval + tv.tv_sec = 0 + tv.tv_usec = posix.suseconds_t(100000) + + set: posix.fd_set + posix.FD_ZERO(&set) + posix.FD_SET(posix.STDIN_FILENO, &set) + + ready := posix.select(1, &set, nil, nil, &tv) + if ready <= 0 { + return .Escape + } + + n2 := posix.read(posix.STDIN_FILENO, &buf[1], 1) + if n2 <= 0 || buf[1] != '[' { + return .Escape + } + + posix.FD_ZERO(&set) + posix.FD_SET(posix.STDIN_FILENO, &set) + tv.tv_sec = 0 + tv.tv_usec = posix.suseconds_t(100000) + + ready = posix.select(1, &set, nil, nil, &tv) + if ready <= 0 { + return .Escape + } + + n3 := posix.read(posix.STDIN_FILENO, &buf[2], 1) + if n3 <= 0 { + return .Escape + } + + switch buf[2] { + case 'A': + return .Up + case 'B': + return .Down + case: + return .Escape + } + case: + return .Unknown + } +} + +MultiSelect_Result :: enum { + Confirm, + Cancel, +} + +MAX_VISIBLE :: 7 + +multi_select :: proc( + prompt: string, + options: []string, +) -> (selected: [dynamic]bool, result: MultiSelect_Result) { + if len(options) == 0 { + return + } + + selected = make([dynamic]bool, len(options)) + cursor: int = 0 + scroll_offset: int = 0 + + fmt.printf("\x1b[?25l") + visible := render_options(prompt, options, selected[:], cursor, scroll_offset) + + raw, ok := enable_raw_mode(posix.STDIN_FILENO) + if !ok { + fmt.printf("\x1b[?25h") + return + } + defer disable_raw_mode(&raw) + + for { + key := read_key() + + switch key { + case .Up: + if cursor > 0 { + cursor -= 1 + } + case .Down: + if cursor < len(options) - 1 { + cursor += 1 + } + case .Space: + selected[cursor] = !selected[cursor] + case .Enter: + fmt.printf("\x1b[%dA\x1b[J\x1b[?25h", visible + 1) + result = .Confirm + return + case .Escape: + fmt.printf("\x1b[%dA\x1b[J\x1b[?25h", visible + 1) + result = .Cancel + return + case .Unknown: + } + + scroll_offset = max(0, min(cursor - MAX_VISIBLE / 2, len(options) - MAX_VISIBLE)) + fmt.printf("\x1b[%dA\x1b[0J", visible + 1) + visible = render_options(prompt, options, selected[:], cursor, scroll_offset) + } +} + +render_options :: proc(prompt: string, options: []string, selected: []bool, cursor: int, scroll_offset: int) -> int { + fmt.printf( + "\x1b[1;36m%s\x1b[0m (↑/↓ move, space select, enter confirm)\r\n", + prompt, + ) + + end := scroll_offset + MAX_VISIBLE + if end > len(options) { + end = len(options) + } + + for i in scroll_offset.. \x1b[0m[\x1b[32m%s\x1b[0m] %s\r\n", checkbox, options[i]) + } else { + fmt.printf(" [\x1b[2m%s\x1b[0m] %s\r\n", checkbox, options[i]) + } + } + + return end - scroll_offset +} diff --git a/scan.odin b/scan.odin index ab8e556..7476342 100644 --- a/scan.odin +++ b/scan.odin @@ -90,12 +90,18 @@ run_fd :: proc(args: []string) -> (lines: [dynamic]string, ok: bool) { } scan_path :: proc(search_path: string, cfg: Config) -> (paths: [dynamic]string, ok: bool) { + if is_tty() { + fmt.printf("Searching for all files in \"%s\"...\n", search_path) + } all_args := build_fd_args(search_path, cfg, true) all_files, all_ok := run_fd(all_args) if !all_ok { return } + if is_tty() { + fmt.printf("Search for unignored fies in \"%s\"...\n", search_path) + } unignored_args := build_fd_args(search_path, cfg, false) unignored_files, unignored_ok := run_fd(unignored_args) if !unignored_ok { diff --git a/stubs.odin b/stubs.odin index cbfc94b..1c0034b 100644 --- a/stubs.odin +++ b/stubs.odin @@ -6,10 +6,6 @@ cmd_init :: proc(cmd: ^Command) { fmt.println("TODO: init") } -cmd_scan :: proc(cmd: ^Command) { - fmt.println("TODO: scan") -} - cmd_sync :: proc(cmd: ^Command) { fmt.println("TODO: sync") } diff --git a/tty.odin b/tty.odin index 61282a5..8f7cb8e 100644 --- a/tty.odin +++ b/tty.odin @@ -5,3 +5,4 @@ import "core:sys/posix" is_tty :: proc() -> bool { return bool(posix.isatty(1)) } +