diff --git a/TODOS.md b/TODOS.md index decd465..64ce93d 100644 --- a/TODOS.md +++ b/TODOS.md @@ -56,34 +56,34 @@ 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. +28. 2 scan tests silently skip when fd isn't installed, tests pass without actually testing anything. These should use #assert to be sure that fd is in path. 38. Try to do all encryption / decryption in memory - only read / write encrypted data to disk. 40. use a buffered writer where possible (mem.DEFAULT_PAGE_SIZE) +41. add --format -f flag to commands that draw tables. + +42. Replace `testing.expect` calls with `testing.expect_value` calls where appropriate. + ## Double-check AI output - [ ] cli.odin - [ ] cli_test.odin -- [ ] cmd_backup.odin -- [ ] cmd_check.odin +- [x] cmd_backup.odin +- [x] cmd_check.odin - [ ] cmd_check_test.odin -- [ ] cmd_deps.odin +- [x] cmd_deps.odin - [ ] cmd_edit_config.odin -- [ ] cmd_init.odin -- [ ] cmd_list.odin +- [x] cmd_init.odin +- [x] cmd_list.odin - [ ] cmd_list_test.odin -- [ ] cmd_nushell_completion.odin -- [ ] cmd_nushell_completion_test.odin -- [ ] cmd_remove.odin -- [ ] cmd_restore.odin -- [ ] cmd_scan.odin -- [ ] cmd_sync.odin +- [x] cmd_nushell_completion.odin +- [x] cmd_nushell_completion_test.odin +- [x] cmd_remove.odin +- [x] cmd_restore.odin +- [x] cmd_scan.odin +- [x] cmd_sync.odin - [x] cmd_version.odin - [ ] config.odin - [ ] config_test.odin @@ -92,10 +92,10 @@ Note: These todos can wait until all the subcommands have been ported. - [ ] db.odin - [ ] db_integration_test.odin - [ ] db_test.odin -- [ ] features.odin -- [ ] features_test.odin -- [ ] main.odin -- [ ] prompt.odin +- [x] features.odin +- [x] features_test.odin +- [x] main.odin +- [x] prompt.odin - [ ] scan.odin - [ ] scan_test.odin - [ ] sodium.odin diff --git a/cli.odin b/cli.odin index e5efa2e..4d61b63 100644 --- a/cli.odin +++ b/cli.odin @@ -56,6 +56,7 @@ COMMANDS := []CommandInfo { }, } +// FIXME: Works in kinda a wonky and awkward way. parse_args :: proc(args: []string) -> (cmd: Command, ok: bool) { if len(args) < 2 || args[1] == "--help" || args[1] == "-h" { print_usage() @@ -157,7 +158,12 @@ 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 -c, --config-file config file (default \"~/.envr/config.json\")\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 } @@ -175,6 +181,7 @@ print_command_help :: proc(name: string) { bufio.writer_flush(&bw) } +// TODO: command args should be shown in usage. write_usage :: proc(w: io.Writer) { fmt.wprintf( w, diff --git a/cmd_backup.odin b/cmd_backup.odin index bcc0485..a91e24c 100644 --- a/cmd_backup.odin +++ b/cmd_backup.odin @@ -17,19 +17,16 @@ cmd_backup :: proc(cmd: ^Command) { file, ok := new_env_file(path) if !ok { - // TODO: log a message return } db, db_ok := db_open(cmd.config_path) if !db_ok { - // TODO: log a message return } defer db_close(&db) if !db_insert(&db, file) { - // TODO: log a message return } diff --git a/cmd_check.odin b/cmd_check.odin index f373a50..028e440 100644 --- a/cmd_check.odin +++ b/cmd_check.odin @@ -11,7 +11,7 @@ cmd_check :: proc(cmd: ^Command) { if len(cmd.args) > 0 { check_path = cmd.args[0] } else { - cwd, cwd_err := os.get_working_directory(context.allocator) + cwd, cwd_err := os.get_working_directory(context.temp_allocator) if cwd_err != nil { fmt.printf("Error getting current directory: %v\n", cwd_err) return diff --git a/cmd_deps.odin b/cmd_deps.odin index 0a62d33..8ae3bfd 100644 --- a/cmd_deps.odin +++ b/cmd_deps.odin @@ -1,10 +1,10 @@ package main -import "core:fmt" import "core:io" import "core:os" import "core:terminal" +// TODO: Improve table rendering cmd_deps :: proc(cmd: ^Command) { feats := check_features() diff --git a/cmd_init.odin b/cmd_init.odin index 2eb581c..8eef822 100644 --- a/cmd_init.odin +++ b/cmd_init.odin @@ -5,10 +5,14 @@ import "core:fmt" cmd_init :: proc(cmd: ^Command) { force := has_flag(cmd, "force") || has_flag(cmd, "f") + fmt.println(cmd.config_path) + _, 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.") + fmt.println( + `You have already initialized envr. +Run again with the --force flag if you want to reinitialize.`, + ) return } @@ -18,12 +22,13 @@ cmd_init :: proc(cmd: ^Command) { } if len(keys) == 0 { - fmt.println("No ssh-ed25519 keys found in ~/.ssh") - fmt.println("Generate one with: ssh-keygen -t ed25519") + fmt.println(`No ssh-ed25519 keys found in ~/.ssh +Generate one with: ssh-keygen -t ed25519`) return } selected, result := multi_select("Select SSH private keys:", keys[:]) + defer delete(selected) if result == .Cancel { fmt.println("\x1b[2mCancelled.\x1b[0m") return diff --git a/cmd_list.odin b/cmd_list.odin index 9f9e771..c263763 100644 --- a/cmd_list.odin +++ b/cmd_list.odin @@ -13,6 +13,8 @@ ListEntry :: struct { Path: string `json:"path"`, } +// TODO: Support --format flag +// TODO: Improve table rendering cmd_list :: proc(cmd: ^Command) { db, db_ok := db_open(cmd.config_path) if !db_ok { @@ -42,6 +44,7 @@ cmd_list :: proc(cmd: ^Command) { w := io.to_writer(os.to_writer(os.stdout)) render_table(w, headers, table_rows[:]) } else { + // TODO: Should we instead print full entries here? entries: [dynamic]ListEntry for row in rows { filename := filepath.base(row.Path) @@ -54,7 +57,7 @@ cmd_list :: proc(cmd: ^Command) { ) } - data, marshal_err := json.marshal(entries[:]) + data, marshal_err := json.marshal(entries[:], allocator = context.temp_allocator) if marshal_err != nil { fmt.printf("Error marshaling JSON: %v\n", marshal_err) return diff --git a/cmd_nushell_completion.odin b/cmd_nushell_completion.odin index a3ffcd0..df29172 100644 --- a/cmd_nushell_completion.odin +++ b/cmd_nushell_completion.odin @@ -5,5 +5,7 @@ import "core:fmt" COMPLETION_SCRIPT: string : string(#load("mod.nu")) cmd_nushell_completion :: proc(cmd: ^Command) { + // TODO: Use buffered writer? fmt.print(COMPLETION_SCRIPT) } + diff --git a/cmd_remove.odin b/cmd_remove.odin index 21eeead..8027723 100644 --- a/cmd_remove.odin +++ b/cmd_remove.odin @@ -5,38 +5,40 @@ import "core:path/filepath" import "core:strings" cmd_remove :: proc(cmd: ^Command) { - if len(cmd.args) != 1 { + if len(cmd.args) != 1 { print_command_help("remove") - return - } + return + } - path := cmd.args[0] - if len(strings.trim_space(path)) == 0 { - fmt.println("Error: No path provided") - return - } + path := cmd.args[0] + if len(strings.trim_space(path)) == 0 { + fmt.println("Error: No path provided") + return + } - abs_path: string - if filepath.is_abs(path) { - abs_path = path - } else { - resolved, abs_err := filepath.abs(path) - if abs_err != nil { - fmt.printf("Error getting absolute path: %v\n", abs_err) - return - } - abs_path = resolved - } + // TODO: Is this the best way to do it? + abs_path: string + if filepath.is_abs(path) { + abs_path = path + } else { + resolved, abs_err := filepath.abs(path) + if abs_err != nil { + fmt.printf("Error getting absolute path: %v\n", abs_err) + return + } + abs_path = resolved + } - db, db_ok := db_open(cmd.config_path) - if !db_ok { - return - } - defer db_close(&db) + db, db_ok := db_open(cmd.config_path) + if !db_ok { + return + } + defer db_close(&db) - if !db_delete(&db, abs_path) { - return - } + if !db_delete(&db, abs_path) { + return + } - fmt.printf("Removed %s from the database\n", abs_path) + fmt.printf("Removed %s from the database\n", abs_path) } + diff --git a/cmd_restore.odin b/cmd_restore.odin index 1d8f44f..2e0355d 100644 --- a/cmd_restore.odin +++ b/cmd_restore.odin @@ -6,48 +6,50 @@ import "core:path/filepath" import "core:strings" cmd_restore :: proc(cmd: ^Command) { - if len(cmd.args) != 1 { + if len(cmd.args) != 1 { print_command_help("restore") - return - } + return + } - path := cmd.args[0] - if len(strings.trim_space(path)) == 0 { - fmt.println("Error: No path provided") - return - } + path := cmd.args[0] + if len(strings.trim_space(path)) == 0 { + fmt.println("Error: No path provided") + return + } - abs_path: string - if filepath.is_abs(path) { - abs_path = path - } else { - resolved, abs_err := filepath.abs(path) - if abs_err != nil { - fmt.printf("Error getting absolute path: %v\n", abs_err) - return - } - abs_path = resolved - } + // TODO: Is this the right way to handle this? + abs_path: string + if filepath.is_abs(path) { + abs_path = path + } else { + resolved, abs_err := filepath.abs(path) + if abs_err != nil { + fmt.printf("Error getting absolute path: %v\n", abs_err) + return + } + abs_path = resolved + } - db, db_ok := db_open(cmd.config_path) - if !db_ok { - return - } - defer db_close(&db) + db, db_ok := db_open(cmd.config_path) + if !db_ok { + return + } + defer db_close(&db) - file, fetch_ok := db_fetch(&db, abs_path) - if !fetch_ok { - return - } + file, fetch_ok := db_fetch(&db, abs_path) + if !fetch_ok { + return + } - dir := filepath.dir(file.Path) - os.mkdir_all(dir) + dir := filepath.dir(file.Path) + os.mkdir_all(dir) - write_err := os.write_entire_file(file.Path, file.contents) - if write_err != nil { - fmt.printf("Error writing file: %v\n", write_err) - return - } + write_err := os.write_entire_file(file.Path, file.contents) + if write_err != nil { + fmt.printf("Error writing file: %v\n", write_err) + return + } - fmt.printf("Restored %s\n", file.Path) + fmt.printf("Restored %s\n", file.Path) } + diff --git a/cmd_scan.odin b/cmd_scan.odin index 99510c5..a9e2566 100644 --- a/cmd_scan.odin +++ b/cmd_scan.odin @@ -22,7 +22,7 @@ cmd_scan :: proc(cmd: ^Command) { 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.") + fmt.println("No search paths configured. Please run `envr init -f` or edit your config.") return } @@ -62,6 +62,7 @@ cmd_scan :: proc(cmd: ^Command) { } selected, result := multi_select("Select .env files to backup:", files[:]) + defer delete(selected) if result == .Cancel { fmt.println("\x1b[2mCancelled.\x1b[0m") return diff --git a/cmd_sync.odin b/cmd_sync.odin index d589a43..0d33c92 100644 --- a/cmd_sync.odin +++ b/cmd_sync.odin @@ -13,6 +13,7 @@ SyncEntry :: struct { } // TODO: Check for quiet failures. +// TODO: Support --format -f flags cmd_sync :: proc(cmd: ^Command) { db, db_ok := db_open(cmd.config_path) if !db_ok { @@ -26,11 +27,13 @@ cmd_sync :: proc(cmd: ^Command) { } defer delete(files) + // TODO: Set sane default size results: [dynamic]SyncEntry + defer delete(results) for &file in files { old_path: string - old_path, _ = strings.clone(file.Path) + old_path, _ = strings.clone(file.Path, context.temp_allocator) result, err_msg := db_sync(&db, &file) diff --git a/config.odin b/config.odin index 77c1e4b..a8e88f1 100644 --- a/config.odin +++ b/config.odin @@ -24,8 +24,10 @@ Config :: struct { } default_config_path :: proc(home: string) -> string { - // FIXME: catch error - path, _ := filepath.join([]string{home, ".envr", "config.json"}) + path, err := filepath.join([]string{home, ".envr", "config.json"}) + if err != nil { + panic("Ran out of memory when building config path") + } return path } @@ -37,6 +39,7 @@ load_config :: proc(config_path: string) -> (Config, bool) { } cfg: Config + // TODO: use json 5 err := json.unmarshal(data, &cfg) if err != nil { fmt.printf("Error parsing config: %v\n", err) diff --git a/crypto_test.odin b/crypto_test.odin index b8d8a6a..5bcbe8c 100644 --- a/crypto_test.odin +++ b/crypto_test.odin @@ -25,7 +25,11 @@ test_encrypt_decrypt_roundtrip :: proc(t: ^testing.T) { testing.expect(t, dec_ok, "decryption should succeed") defer delete(decrypted) - testing.expect(t, len(decrypted) == len(original), fmt.tprintf("expected %d bytes, got %d", len(original), len(decrypted))) + testing.expect( + t, + len(decrypted) == len(original), + fmt.tprintf("expected %d bytes, got %d", len(original), len(decrypted)), + ) for i in 0 ..< len(original) { testing.expect(t, decrypted[i] == original[i], fmt.tprintf("byte mismatch at index %d", i)) } @@ -50,8 +54,16 @@ test_encrypt_decrypt_multi_recipient :: proc(t: ^testing.T) { defer delete(decrypted2) for i in 0 ..< len(original) { - testing.expect(t, decrypted1[i] == original[i], fmt.tprintf("key1: byte mismatch at %d", i)) - testing.expect(t, decrypted2[i] == original[i], fmt.tprintf("key2: byte mismatch at %d", i)) + testing.expect( + t, + decrypted1[i] == original[i], + fmt.tprintf("key1: byte mismatch at %d", i), + ) + testing.expect( + t, + decrypted2[i] == original[i], + fmt.tprintf("key2: byte mismatch at %d", i), + ) } } @@ -85,6 +97,25 @@ test_encrypt_empty_plaintext :: proc(t: ^testing.T) { testing.expect(t, len(decrypted) == 0, "decrypted empty data should be empty") } +@(test) +test_recipient_can_decrypt_senders_data :: proc(t: ^testing.T) { + key1 := make_test_key_pair("test_ed25519") + key2 := make_test_key_pair("test_ed25519_second") + original := []u8{10, 20, 30, 40, 50} + + encrypted, enc_ok := encrypt(original, []SshKeyPair{key1, key2}) + testing.expect(t, enc_ok, "encryption with 2 keys should succeed") + defer delete(encrypted) + + decrypted, dec_ok := decrypt(encrypted, []SshKeyPair{key2}) + testing.expect(t, dec_ok, "second recipient should decrypt without the sender key present") + defer delete(decrypted) + + for i in 0 ..< len(original) { + testing.expect(t, decrypted[i] == original[i], fmt.tprintf("byte mismatch at %d", i)) + } +} + @(test) test_ciphertext_has_magic :: proc(t: ^testing.T) { key := make_test_key_pair("test_ed25519") @@ -100,3 +131,4 @@ test_ciphertext_has_magic :: proc(t: ^testing.T) { testing.expect(t, encrypted[2] == u8('V'), "magic byte 2") testing.expect(t, encrypted[3] == u8('R'), "magic byte 3") } + diff --git a/db.odin b/db.odin index c15daf6..60a2823 100644 --- a/db.odin +++ b/db.odin @@ -27,6 +27,7 @@ SyncDirection :: enum { } Db :: struct { + // Pointer to the sqlite db db: ^rawptr, cfg: Config, changed: bool, @@ -64,8 +65,8 @@ db_open :: proc(cfg_path: string) -> (Db, bool) { return Db{}, false } - create_sql := "CREATE TABLE IF NOT EXISTS envr_env_files (path TEXT PRIMARY KEY NOT NULL, remotes TEXT, sha256 TEXT NOT NULL, contents TEXT NOT NULL)" - rc = sqlite.db_exec(db, string_to_cstring(create_sql), nil, nil, nil) + create_sql: cstring = "CREATE TABLE IF NOT EXISTS envr_env_files (path TEXT PRIMARY KEY NOT NULL, remotes TEXT, sha256 TEXT NOT NULL, contents TEXT NOT NULL)" + rc = sqlite.db_exec(db, create_sql, nil, nil, nil) if rc != sqlite.OK { fmt.printf("Error creating table: %s\n", sqlite.db_errmsg(db)) sqlite.db_close(db) @@ -125,10 +126,15 @@ db_close :: proc(d: ^Db) { sqlite.db_close(d.db) } -db_list :: proc(d: ^Db) -> (results: [dynamic]EnvFile, ok: bool) { - sql := "SELECT path, remotes, sha256, contents FROM envr_env_files" +db_list :: proc(d: ^Db, allocator := context.allocator) -> (results: [dynamic]EnvFile, ok: bool) { stmt: ^rawptr - rc := sqlite.prepare_v2(d.db, string_to_cstring(sql), -1, &stmt, nil) + rc := sqlite.prepare_v2( + d.db, + "SELECT path, remotes, sha256, contents FROM envr_env_files", + -1, + &stmt, + nil, + ) if rc != sqlite.OK { fmt.printf("Error preparing query: %s\n", sqlite.db_errmsg(d.db)) return @@ -145,15 +151,12 @@ db_list :: proc(d: ^Db) -> (results: [dynamic]EnvFile, ok: bool) { return } - path := cstring_to_string(sqlite.column_text(stmt, 0)) - remotes_json := cstring_to_string(sqlite.column_text(stmt, 1)) - sha := cstring_to_string(sqlite.column_text(stmt, 2)) - contents := cstring_to_string(sqlite.column_text(stmt, 3)) - - remotes: [dynamic]string + remotes_json := string(sqlite.column_text(stmt, 1)) + remotes := make([dynamic]string, strings.count(remotes_json, ",") + 1, allocator) if len(remotes_json) > 0 { json.unmarshal_string(remotes_json, &remotes) } + path := clone_cstring(sqlite.column_text(stmt, 0), allocator) append( &results, @@ -161,8 +164,8 @@ db_list :: proc(d: ^Db) -> (results: [dynamic]EnvFile, ok: bool) { Path = path, Dir = filepath.dir(path), Remotes = remotes, - Sha256 = sha, - contents = contents, + Sha256 = clone_cstring(sqlite.column_text(stmt, 2), allocator), + contents = clone_cstring(sqlite.column_text(stmt, 3), allocator), }, ) } @@ -176,8 +179,7 @@ db_vacuum_to_file :: proc(db: ^rawptr, path: string) -> bool { b: strings.Builder strings.builder_init(&b) fmt.sbprintf(&b, "VACUUM INTO '%s'", path) - sql := strings.to_string(b) - rc := sqlite.db_exec(db, string_to_cstring(sql), nil, nil, nil) + rc := sqlite.db_exec(db, to_cstring(&b), nil, nil, nil) if rc != sqlite.OK { fmt.printf("Error vacuuming database: %s\n", sqlite.db_errmsg(db)) return false @@ -221,7 +223,7 @@ db_attach_and_copy :: proc(mem_db: ^rawptr, src_path: string) -> bool { fmt.sbprintf(&b, "ATTACH DATABASE '%s' AS source", src_path) attach_sql := strings.to_string(b) - rc := sqlite.db_exec(mem_db, string_to_cstring(attach_sql), nil, nil, nil) + rc := sqlite.db_exec(mem_db, to_cstring(attach_sql), nil, nil, nil) if rc != sqlite.OK { fmt.printf("Error attaching database: %s\n", sqlite.db_errmsg(mem_db)) return false @@ -284,8 +286,7 @@ get_git_remotes :: proc(dir: string) -> [dynamic]string { return remotes } - output_str := string(data) - lines := strings.split(output_str, "\n") + lines := strings.split(string(data), "\n") for &line in lines { line = strings.trim_space(line) @@ -312,7 +313,10 @@ new_env_file :: proc(path: string) -> (EnvFile, bool) { fmt.printf("Error getting absolute path: %v\n", abs_err) return EnvFile{}, false } - cloned_path, _ := strings.clone(abs_path) + cloned_path, err := strings.clone(abs_path) + if err != nil { + panic("Ran out of memory") + } dir := filepath.dir(cloned_path) @@ -325,14 +329,14 @@ new_env_file :: proc(path: string) -> (EnvFile, bool) { } digest := hash.hash_bytes(hash.Algorithm.SHA256, data) + // TODO: Handle error hex_bytes, _ := hex.encode(digest) - sha_str := string(hex_bytes) return EnvFile { Path = cloned_path, Dir = dir, Remotes = remotes, - Sha256 = sha_str, + Sha256 = string(hex_bytes), contents = string(data), }, true @@ -345,19 +349,33 @@ db_insert :: proc(d: ^Db, file: EnvFile) -> bool { return false } - sql := "INSERT OR REPLACE INTO envr_env_files (path, remotes, sha256, contents) VALUES (?, ?, ?, ?)" + sql: cstring = + "INSERT OR REPLACE INTO " + + "envr_env_files (path, remotes, sha256, contents) VALUES (?, ?, ?, ?)" stmt: ^rawptr - rc := sqlite.prepare_v2(d.db, string_to_cstring(sql), -1, &stmt, nil) + rc := sqlite.prepare_v2(d.db, sql, -1, &stmt, nil) if rc != sqlite.OK { fmt.printf("Error preparing insert: %s\n", sqlite.db_errmsg(d.db)) return false } defer sqlite.finalize(stmt) - rc = sqlite.bind_text(stmt, 1, string_to_cstring(file.Path), -1, nil) - rc = sqlite.bind_text(stmt, 2, string_to_cstring(string(remotes_json)), -1, nil) - rc = sqlite.bind_text(stmt, 3, string_to_cstring(file.Sha256), -1, nil) - rc = sqlite.bind_text(stmt, 4, string_to_cstring(file.contents), -1, nil) + // TODO: deal with elsewhere? + cpath := to_cstring(file.Path) + defer delete(cpath) + rc = sqlite.bind_text(stmt, 1, cpath, -1, nil) + + cremotes := to_cstring(string(remotes_json)) + defer delete(cremotes) + rc = sqlite.bind_text(stmt, 2, cremotes, -1, nil) + + csha := to_cstring(file.Sha256) + defer delete(csha) + rc = sqlite.bind_text(stmt, 3, csha, -1, nil) + + ccontents := to_cstring(file.contents) + defer delete(ccontents) + rc = sqlite.bind_text(stmt, 4, ccontents, -1, nil) rc = sqlite.step(stmt) if rc != sqlite.DONE { @@ -369,17 +387,19 @@ db_insert :: proc(d: ^Db, file: EnvFile) -> bool { return true } -db_fetch :: proc(d: ^Db, path: string) -> (EnvFile, bool) { - sql := "SELECT path, remotes, sha256, contents FROM envr_env_files WHERE path = ?" +db_fetch :: proc(d: ^Db, path: string, allocator := context.allocator) -> (EnvFile, bool) { + sql: cstring = "SELECT path, remotes, sha256, contents FROM envr_env_files WHERE path = ?" stmt: ^rawptr - rc := sqlite.prepare_v2(d.db, string_to_cstring(sql), -1, &stmt, nil) + rc := sqlite.prepare_v2(d.db, sql, -1, &stmt, nil) if rc != sqlite.OK { fmt.printf("Error preparing fetch: %s\n", sqlite.db_errmsg(d.db)) return EnvFile{}, false } defer sqlite.finalize(stmt) - rc = sqlite.bind_text(stmt, 1, string_to_cstring(path), -1, nil) + cpath := to_cstring(path, allocator) + defer delete(cpath, allocator) + rc = sqlite.bind_text(stmt, 1, cpath, -1, nil) rc = sqlite.step(stmt) if rc == sqlite.DONE { fmt.printf("No file found with path: %s\n", path) @@ -390,38 +410,37 @@ db_fetch :: proc(d: ^Db, path: string) -> (EnvFile, bool) { return EnvFile{}, false } - file_path := cstring_to_string(sqlite.column_text(stmt, 0)) - remotes_json := cstring_to_string(sqlite.column_text(stmt, 1)) - sha := cstring_to_string(sqlite.column_text(stmt, 2)) - contents := cstring_to_string(sqlite.column_text(stmt, 3)) - - remotes: [dynamic]string + remotes_json := string(sqlite.column_text(stmt, 1)) + remotes := make([dynamic]string, strings.count(remotes_json, ",") + 1, allocator) if len(remotes_json) > 0 { - json.unmarshal_string(remotes_json, &remotes) + json.unmarshal_string(remotes_json, &remotes, allocator = allocator) } - cloned_path, _ := strings.clone(file_path) + file_path := clone_cstring(sqlite.column_text(stmt, 0)) + return EnvFile { - Path = cloned_path, - Dir = filepath.dir(cloned_path), + Path = file_path, + Dir = filepath.dir(file_path), Remotes = remotes, - Sha256 = sha, - contents = contents, + Sha256 = clone_cstring(sqlite.column_text(stmt, 2), allocator), + contents = clone_cstring(sqlite.column_text(stmt, 3), allocator), }, true } db_delete :: proc(d: ^Db, path: string) -> bool { - sql := "DELETE FROM envr_env_files WHERE path = ?" + sql: cstring = "DELETE FROM envr_env_files WHERE path = ?" stmt: ^rawptr - rc := sqlite.prepare_v2(d.db, string_to_cstring(sql), -1, &stmt, nil) + rc := sqlite.prepare_v2(d.db, sql, -1, &stmt, nil) if rc != sqlite.OK { fmt.printf("Error preparing delete: %s\n", sqlite.db_errmsg(d.db)) return false } defer sqlite.finalize(stmt) - rc = sqlite.bind_text(stmt, 1, string_to_cstring(path), -1, nil) + cpath := to_cstring(path) + defer delete(cpath) + rc = sqlite.bind_text(stmt, 1, cpath, -1, nil) rc = sqlite.step(stmt) if rc != sqlite.DONE { fmt.printf("Error deleting: %s\n", sqlite.db_errmsg(d.db)) @@ -437,19 +456,30 @@ db_delete :: proc(d: ^Db, path: string) -> bool { return true } -cstring_to_string :: proc(cs: cstring) -> string { - if cs == nil { - return "" - } - s, _ := strings.clone_from_cstring(cs) - return s +to_cstring :: proc { + string_to_cstring, + strings.to_cstring, } -string_to_cstring :: proc(s: string) -> cstring { - cs, _ := strings.clone_to_cstring(s) +string_to_cstring :: proc(s: string, allocator := context.allocator) -> cstring { + cs, err := strings.clone_to_cstring(s, allocator) + if err != nil { + fmt.printf("Failed to convert string to cstring: %v\n", err) + panic("Allocation Exception") + } return cs } +clone_cstring :: proc(c: cstring, allocator := context.allocator) -> string { + str, err := strings.clone_from_cstring(c, allocator) + if err != nil { + fmt.printf("Failed to convert string to cstring: %v\n", err) + panic("Allocation Exception") + } + + return str +} + db_update_required :: proc(status: SyncFlag) -> bool { return .BackedUp in status || .DirUpdated in status } @@ -505,7 +535,11 @@ env_file_backup :: proc(f: ^EnvFile) -> bool { f.contents = string(data) digest := hash.hash_bytes(hash.Algorithm.SHA256, data) - hex_bytes, _ := hex.encode(digest) + hex_bytes, alloc_err := hex.encode(digest) + if alloc_err != nil { + fmt.printf("Error generating hash for file %s: %v\n", f.Path, alloc_err) + return false + } f.Sha256 = string(hex_bytes) return true } @@ -555,6 +589,7 @@ env_file_sync :: proc(f: ^EnvFile, dir: SyncDirection, d: ^Db) -> (SyncFlag, str } digest := hash.hash_bytes(hash.Algorithm.SHA256, data) + // TODO: Handle error hex_bytes, _ := hex.encode(digest) current_sha := string(hex_bytes) diff --git a/db_integration_test.odin b/db_integration_test.odin index 62efdc2..d02457f 100644 --- a/db_integration_test.odin +++ b/db_integration_test.odin @@ -199,7 +199,7 @@ test_decrypt_then_attach_sqlite :: proc(t: ^testing.T) { rc = sqlite.step(stmt) testing.expect(t, rc == sqlite.ROW, "expected at least one row") if rc == sqlite.ROW { - path := cstring_to_string(sqlite.column_text(stmt, 0)) + path := string(sqlite.column_text(stmt, 0)) testing.expect(t, len(path) > 0, "path should not be empty") } } diff --git a/db_test.odin b/db_test.odin index 1a98b4a..e9e4c31 100644 --- a/db_test.odin +++ b/db_test.odin @@ -46,12 +46,11 @@ test_db_insert_and_fetch :: proc(t: ^testing.T) { if !ok do return defer sqlite.db_close(d.db) - f := make_test_env_file( - "/project/.env", - "abc123", - "SECRET=value", - []string{"git@github.com:user/repo.git"}, - ) + path := "/project/.env" + sha := "abc123" + contents := "SECRET=value" + + f := make_test_env_file(path, sha, contents, []string{"git@github.com:user/repo.git"}) defer delete(f.Remotes) testing.expect(t, db_insert(&d, f), "insert should succeed") @@ -61,11 +60,11 @@ test_db_insert_and_fetch :: proc(t: ^testing.T) { if !fetch_ok do return defer delete(fetched.Remotes) - testing.expect(t, fetched.Path == "/project/.env", "path mismatch") - testing.expect(t, fetched.Sha256 == "abc123", "sha mismatch") - testing.expect(t, fetched.contents == "SECRET=value", "contents mismatch") - testing.expect(t, len(fetched.Remotes) == 1, "remotes count mismatch") - testing.expect(t, fetched.Remotes[0] == "git@github.com:user/repo.git", "remote mismatch") + testing.expect_value(t, fetched.Path, path) + testing.expect_value(t, fetched.Sha256, sha) + testing.expect_value(t, fetched.contents, contents) + testing.expect_value(t, len(fetched.Remotes), 1) + testing.expect_value(t, fetched.Remotes[0], "git@github.com:user/repo.git") } @(test) diff --git a/prompt.odin b/prompt.odin index 4bce000..9c6cb4d 100644 --- a/prompt.odin +++ b/prompt.odin @@ -113,10 +113,14 @@ MultiSelect_Result :: enum { MAX_VISIBLE :: 7 +// Caller is responsible for deleting the responses. multi_select :: proc( prompt: string, options: []string, -) -> (selected: [dynamic]bool, result: MultiSelect_Result) { +) -> ( + selected: [dynamic]bool, + result: MultiSelect_Result, +) { if len(options) == 0 { return } @@ -166,18 +170,21 @@ multi_select :: proc( } } -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, - ) +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.. (lines: []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 { + // TODO: Log a message here return }