From 67f735a654413c31b5c14ec55308b4919581ad14 Mon Sep 17 00:00:00 2001 From: Spencer Brower Date: Fri, 12 Jun 2026 14:19:58 -0400 Subject: [PATCH] test: Added tests. --- cli_test.odin | 49 +++ config.odin | 23 +- config_test.odin | 63 ++++ db.odin | 942 ++++++++++++++++++++++++----------------------- db_test.odin | 90 +++++ scan_test.odin | 4 +- 6 files changed, 696 insertions(+), 475 deletions(-) create mode 100644 config_test.odin create mode 100644 db_test.odin diff --git a/cli_test.odin b/cli_test.odin index aed5400..5aa22c0 100644 --- a/cli_test.odin +++ b/cli_test.odin @@ -1,3 +1,5 @@ +#+feature dynamic-literals + package main import "core:fmt" @@ -140,3 +142,50 @@ test_command_help_version :: proc(t: ^testing.T) { ) } +@(test) +test_has_flag_bool_set :: proc(t: ^testing.T) { + cmd := Command { + name = "test", + bool_set = map[string]bool{"force" = true}, + } + defer delete(cmd.bool_set) + + testing.expect(t, has_flag(&cmd, "force"), "should find flag in bool_set") + testing.expect(t, !has_flag(&cmd, "verbose"), "should not find missing flag") +} + +@(test) +test_has_flag_value_map :: proc(t: ^testing.T) { + cmd := Command { + name = "test", + flags = map[string]string{"output" = "/tmp/out"}, + } + defer delete(cmd.flags) + + testing.expect(t, has_flag(&cmd, "output"), "should find flag in flags map") + testing.expect(t, !has_flag(&cmd, "force"), "should not find missing flag") +} + +@(test) +test_has_flag_both_maps :: proc(t: ^testing.T) { + cmd := Command { + name = "test", + flags = map[string]string{"output" = "/tmp/out"}, + bool_set = map[string]bool{"force" = true}, + } + defer delete(cmd.flags) + defer delete(cmd.bool_set) + + testing.expect(t, has_flag(&cmd, "output"), "should find in flags") + testing.expect(t, has_flag(&cmd, "force"), "should find in bool_set") + testing.expect(t, !has_flag(&cmd, "verbose"), "should not find missing flag") +} + +@(test) +test_has_flag_empty_command :: proc(t: ^testing.T) { + cmd := Command { + name = "test", + } + testing.expect(t, !has_flag(&cmd, "anything"), "empty command should have no flags") +} + diff --git a/config.odin b/config.odin index 50d82a8..f9637c8 100644 --- a/config.odin +++ b/config.odin @@ -13,17 +13,17 @@ SshKeyPair :: struct { ScanConfig :: struct { Matcher: string `json:"matcher"`, - Exclude: []string `json:"exclude"`, - Include: []string `json:"include"`, + Exclude: [dynamic]string `json:"exclude"`, + Include: [dynamic]string `json:"include"`, } Config :: struct { - Keys: []SshKeyPair `json:"keys"`, + Keys: [dynamic]SshKeyPair `json:"keys"`, ScanConfig: ScanConfig `json:"scan"`, } load_config :: proc() -> (Config, bool) { - home, home_err := os.user_home_dir(context.allocator) + 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 @@ -49,6 +49,12 @@ load_config :: proc() -> (Config, bool) { return cfg, true } +delete_config :: proc(cfg: Config) { + delete(cfg.Keys) + delete(cfg.ScanConfig.Exclude) + delete(cfg.ScanConfig.Include) +} + envr_dir :: proc() -> string { home, _ := os.user_home_dir(context.allocator) dir, _ := filepath.join([]string{home, ".envr"}) @@ -107,7 +113,8 @@ find_ssh_private_keys :: proc() -> (keys: [dynamic]string, ok: bool) { new_config :: proc(private_key_paths: []string) -> Config { keys := make([dynamic]SshKeyPair, 0, len(private_key_paths)) for priv in private_key_paths { - pub, _ := strings.concatenate([]string{priv, ".pub"}) + // TODO: Is this bad? + pub, _ := strings.concatenate([]string{priv, ".pub"}, context.temp_allocator) append(&keys, SshKeyPair{Private = priv, Public = pub}) } @@ -122,11 +129,11 @@ new_config :: proc(private_key_paths: []string) -> Config { scan_cfg := ScanConfig { Matcher = "\\.env", - Exclude = exclude[:], - Include = include[:], + Exclude = exclude, + Include = include, } - return Config{Keys = keys[:], ScanConfig = scan_cfg} + return Config{Keys = keys, ScanConfig = scan_cfg} } save_config :: proc(cfg: Config, force: bool = false) -> bool { diff --git a/config_test.odin b/config_test.odin new file mode 100644 index 0000000..294702d --- /dev/null +++ b/config_test.odin @@ -0,0 +1,63 @@ +package main + +import "core:testing" + +@(test) +test_new_config_single_key :: proc(t: ^testing.T) { + paths := []string{"/home/user/.ssh/id_ed25519"} + cfg := new_config(paths) + defer delete_config(cfg) + + testing.expect(t, len(cfg.Keys) == 1, "should have 1 key") + testing.expect(t, cfg.Keys[0].Private == "/home/user/.ssh/id_ed25519", "Private path mismatch") + testing.expect( + t, + cfg.Keys[0].Public == "/home/user/.ssh/id_ed25519.pub", + "Public path mismatch", + ) +} + +@(test) +test_new_config_multiple_keys :: proc(t: ^testing.T) { + paths := []string{"/home/user/.ssh/id_ed25519", "/home/user/.ssh/id_rsa"} + cfg := new_config(paths) + defer delete_config(cfg) + + testing.expect(t, len(cfg.Keys) == 2, "should have 2 keys") + testing.expect(t, cfg.Keys[0].Private == "/home/user/.ssh/id_ed25519") + testing.expect(t, cfg.Keys[1].Private == "/home/user/.ssh/id_rsa") +} + +@(test) +test_new_config_empty_keys :: proc(t: ^testing.T) { + paths: []string + cfg := new_config(paths) + defer delete_config(cfg) + + testing.expect(t, len(cfg.Keys) == 0, "should have 0 keys") +} + +@(test) +test_new_config_scan_defaults :: proc(t: ^testing.T) { + paths := []string{"/home/user/.ssh/id_ed25519"} + cfg := new_config(paths) + defer delete_config(cfg) + + testing.expect(t, cfg.ScanConfig.Matcher == "\\.env", "matcher should be \\.env") + testing.expect(t, len(cfg.ScanConfig.Exclude) == 4, "should have 4 exclude patterns") + testing.expect(t, len(cfg.ScanConfig.Include) == 1, "should have 1 include path") + testing.expect(t, cfg.ScanConfig.Include[0] == "~", "include should be ~") +} + +@(test) +test_new_config_exclude_patterns :: proc(t: ^testing.T) { + paths := []string{"/home/user/.ssh/id_ed25519"} + cfg := new_config(paths) + defer delete_config(cfg) + + expected := []string{"*\\.envrc", "\\.local/", "node_modules", "vendor"} + for i in 0 ..< len(expected) { + testing.expect(t, cfg.ScanConfig.Exclude[i] == expected[i]) + } +} + diff --git a/db.odin b/db.odin index a6c117a..1746f10 100644 --- a/db.odin +++ b/db.odin @@ -13,611 +13,623 @@ import "core:time" import "sqlite" SyncResult :: enum i32 { - Noop = 0, - DirUpdated = 1, - Restored = 1 << 1, - BackedUp = 1 << 2, - Error = 1 << 3, + Noop = 0, + DirUpdated = 1, + Restored = 1 << 1, + BackedUp = 1 << 2, + Error = 1 << 3, } SyncDirection :: enum { - TrustDatabase, - TrustFilesystem, + TrustDatabase, + TrustFilesystem, } Db :: struct { - db: ^rawptr, - cfg: Config, - changed: bool, + db: ^rawptr, + cfg: Config, + changed: bool, } EnvFile :: struct { - Path: string, - Dir: string, - Remotes: [dynamic]string, - Sha256: string, - contents: string, + Path: string, + Dir: string, + Remotes: [dynamic]string, + Sha256: string, + contents: string, } make_temp_path :: proc() -> string { - ts := time.time_to_unix(time.now()) - b: strings.Builder - strings.builder_init(&b) - fmt.sbprintf(&b, "/tmp/envr-%d-%d.db", os.get_pid(), ts) - return strings.to_string(b) + ts := time.time_to_unix(time.now()) + b: strings.Builder + strings.builder_init(&b) + fmt.sbprintf(&b, "/tmp/envr-%d-%d.db", os.get_pid(), ts) + return strings.to_string(b) } db_open :: proc() -> (Db, bool) { - cfg, ok := load_config() - if !ok { - return Db{}, false - } + cfg, ok := load_config() + if !ok { + return Db{}, false + } - age_path := data_age_path() - _, stat_err := os.stat(age_path, context.allocator) + age_path := data_age_path() + _, stat_err := os.stat(age_path, context.allocator) - db: ^rawptr - rc := sqlite.db_open(":memory:", &db) - if rc != sqlite.OK { - fmt.printf("Error opening in-memory database: %s\n", sqlite.db_errmsg(db)) - return Db{}, false - } + db: ^rawptr + rc := sqlite.db_open(":memory:", &db) + if rc != sqlite.OK { + fmt.printf("Error opening in-memory database: %s\n", sqlite.db_errmsg(db)) + 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) - if rc != sqlite.OK { - fmt.printf("Error creating table: %s\n", sqlite.db_errmsg(db)) - sqlite.db_close(db) - 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) + if rc != sqlite.OK { + fmt.printf("Error creating table: %s\n", sqlite.db_errmsg(db)) + sqlite.db_close(db) + return Db{}, false + } - if stat_err == nil { - if !db_restore_from_age(db, cfg) { - sqlite.db_close(db) - return Db{}, false - } - } + if stat_err == nil { + if !db_restore_from_age(db, cfg) { + sqlite.db_close(db) + return Db{}, false + } + } - return Db{db = db, cfg = cfg, changed = stat_err != nil}, true + return Db{db = db, cfg = cfg, changed = stat_err != nil}, true } db_close :: proc(d: ^Db) { - if d.changed { - tmp_path := make_temp_path() + if d.changed { + tmp_path := make_temp_path() - if !db_vacuum_to_file(d.db, tmp_path) { - os.remove(tmp_path) - sqlite.db_close(d.db) - return - } + if !db_vacuum_to_file(d.db, tmp_path) { + os.remove(tmp_path) + sqlite.db_close(d.db) + return + } - db_encrypt_file(tmp_path, d.cfg.Keys) - os.remove(tmp_path) - d.changed = false - } - sqlite.db_close(d.db) + db_encrypt_file(tmp_path, d.cfg.Keys[:]) + os.remove(tmp_path) + d.changed = false + } + 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" - stmt: ^rawptr - rc := sqlite.prepare_v2(d.db, string_to_cstring(sql), -1, &stmt, nil) - if rc != sqlite.OK { - fmt.printf("Error preparing query: %s\n", sqlite.db_errmsg(d.db)) - return - } + sql := "SELECT path, remotes, sha256, contents FROM envr_env_files" + stmt: ^rawptr + rc := sqlite.prepare_v2(d.db, string_to_cstring(sql), -1, &stmt, nil) + if rc != sqlite.OK { + fmt.printf("Error preparing query: %s\n", sqlite.db_errmsg(d.db)) + return + } - for { - rc = sqlite.step(stmt) - if rc == sqlite.DONE { - break - } - if rc != sqlite.ROW { - fmt.printf("Error stepping query: %s\n", sqlite.db_errmsg(d.db)) - sqlite.finalize(stmt) - return - } + for { + rc = sqlite.step(stmt) + if rc == sqlite.DONE { + break + } + if rc != sqlite.ROW { + fmt.printf("Error stepping query: %s\n", sqlite.db_errmsg(d.db)) + sqlite.finalize(stmt) + 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)) + 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 - if len(remotes_json) > 0 { - json.unmarshal_string(remotes_json, &remotes) - } + remotes: [dynamic]string + if len(remotes_json) > 0 { + json.unmarshal_string(remotes_json, &remotes) + } - append(&results, EnvFile{ - Path = path, - Dir = filepath.dir(path), - Remotes = remotes, - Sha256 = sha, - contents = contents, - }) - } + append( + &results, + EnvFile { + Path = path, + Dir = filepath.dir(path), + Remotes = remotes, + Sha256 = sha, + contents = contents, + }, + ) + } - sqlite.finalize(stmt) - ok = true - return + sqlite.finalize(stmt) + ok = true + return } 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) - if rc != sqlite.OK { - fmt.printf("Error vacuuming database: %s\n", sqlite.db_errmsg(db)) - return false - } - return true + 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) + if rc != sqlite.OK { + fmt.printf("Error vacuuming database: %s\n", sqlite.db_errmsg(db)) + return false + } + return true } db_restore_from_age :: proc(db: ^rawptr, cfg: Config) -> bool { - tmp_path := make_temp_path() - defer os.remove(tmp_path) + tmp_path := make_temp_path() + defer os.remove(tmp_path) - if !db_decrypt_to_file(tmp_path, cfg.Keys) { - return false - } + if !db_decrypt_to_file(tmp_path, cfg.Keys[:]) { + return false + } - if !db_attach_and_copy(db, tmp_path) { - return false - } + if !db_attach_and_copy(db, tmp_path) { + return false + } - return true + return true } db_decrypt_to_file :: proc(tmp_path: string, keys: []SshKeyPair) -> bool { - age_path := data_age_path() + age_path := data_age_path() - args := make([dynamic]string) - append(&args, "age") - append(&args, "--decrypt") - append(&args, "-o") - append(&args, tmp_path) - for key in keys { - append(&args, "-i") - append(&args, key.Private) - } - append(&args, age_path) + args := make([dynamic]string) + append(&args, "age") + append(&args, "--decrypt") + append(&args, "-o") + append(&args, tmp_path) + for key in keys { + append(&args, "-i") + append(&args, key.Private) + } + append(&args, age_path) - desc := os.Process_Desc{ - command = args[:], - stdout = os.stderr, - stderr = os.stderr, - } + desc := os.Process_Desc { + command = args[:], + stdout = os.stderr, + stderr = os.stderr, + } - p, err := os.process_start(desc) - if err != nil { - fmt.printf("Error running age decrypt: %v\n", err) - return false - } + p, err := os.process_start(desc) + if err != nil { + fmt.printf("Error running age decrypt: %v\n", err) + return false + } - state, wait_err := os.process_wait(p) - if wait_err != nil { - fmt.printf("Error waiting for age: %v\n", wait_err) - return false - } - if state.exit_code != 0 { - fmt.println("Error: age decryption failed") - return false - } - return true + state, wait_err := os.process_wait(p) + if wait_err != nil { + fmt.printf("Error waiting for age: %v\n", wait_err) + return false + } + if state.exit_code != 0 { + fmt.println("Error: age decryption failed") + return false + } + return true } db_encrypt_file :: proc(tmp_path: string, keys: []SshKeyPair) -> bool { - age_path := data_age_path() - envr_d := envr_dir() - os.mkdir_all(envr_d) + age_path := data_age_path() + envr_d := envr_dir() + os.mkdir_all(envr_d) - args := make([dynamic]string) - append(&args, "age") - append(&args, "--encrypt") - for key in keys { - append(&args, "-r") - pub_data, pub_err := os.read_entire_file_from_path(key.Public, context.allocator) - if pub_err != nil { - fmt.printf("Error reading public key: %s\n", key.Public) - return false - } - pub_str := string(pub_data) - if strings.has_suffix(pub_str, "\n") { - pub_str = pub_str[:len(pub_str)-1] - } - append(&args, pub_str) - } - append(&args, "-o") - append(&args, age_path) - append(&args, tmp_path) + args := make([dynamic]string) + append(&args, "age") + append(&args, "--encrypt") + for key in keys { + append(&args, "-r") + pub_data, pub_err := os.read_entire_file_from_path(key.Public, context.allocator) + if pub_err != nil { + fmt.printf("Error reading public key: %s\n", key.Public) + return false + } + pub_str := string(pub_data) + if strings.has_suffix(pub_str, "\n") { + pub_str = pub_str[:len(pub_str) - 1] + } + append(&args, pub_str) + } + append(&args, "-o") + append(&args, age_path) + append(&args, tmp_path) - desc := os.Process_Desc{ - command = args[:], - stdout = os.stderr, - stderr = os.stderr, - } + desc := os.Process_Desc { + command = args[:], + stdout = os.stderr, + stderr = os.stderr, + } - p, err := os.process_start(desc) - if err != nil { - fmt.printf("Error running age encrypt: %v\n", err) - return false - } + p, err := os.process_start(desc) + if err != nil { + fmt.printf("Error running age encrypt: %v\n", err) + return false + } - state, wait_err := os.process_wait(p) - if wait_err != nil { - fmt.printf("Error waiting for age: %v\n", wait_err) - return false - } - if state.exit_code != 0 { - fmt.println("Error: age encryption failed") - return false - } - return true + state, wait_err := os.process_wait(p) + if wait_err != nil { + fmt.printf("Error waiting for age: %v\n", wait_err) + return false + } + if state.exit_code != 0 { + fmt.println("Error: age encryption failed") + return false + } + return true } db_attach_and_copy :: proc(mem_db: ^rawptr, src_path: string) -> bool { - b: strings.Builder - strings.builder_init(&b) - fmt.sbprintf(&b, "ATTACH DATABASE '%s' AS source", src_path) - attach_sql := strings.to_string(b) + b: strings.Builder + strings.builder_init(&b) + 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) - if rc != sqlite.OK { - fmt.printf("Error attaching database: %s\n", sqlite.db_errmsg(mem_db)) - return false - } + rc := sqlite.db_exec(mem_db, string_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 + } - rc = sqlite.db_exec(mem_db, "INSERT INTO main.envr_env_files SELECT * FROM source.envr_env_files", nil, nil, nil) - if rc != sqlite.OK { - fmt.printf("Error copying data: %s\n", sqlite.db_errmsg(mem_db)) - sqlite.db_exec(mem_db, "DETACH DATABASE source", nil, nil, nil) - return false - } + rc = sqlite.db_exec( + mem_db, + "INSERT INTO main.envr_env_files SELECT * FROM source.envr_env_files", + nil, + nil, + nil, + ) + if rc != sqlite.OK { + fmt.printf("Error copying data: %s\n", sqlite.db_errmsg(mem_db)) + sqlite.db_exec(mem_db, "DETACH DATABASE source", nil, nil, nil) + return false + } - sqlite.db_exec(mem_db, "DETACH DATABASE source", nil, nil, nil) - return true + sqlite.db_exec(mem_db, "DETACH DATABASE source", nil, nil, nil) + return true } get_git_remotes :: proc(dir: string) -> [dynamic]string { - remotes: [dynamic]string - remote_set: map[string]bool + remotes: [dynamic]string + remote_set: map[string]bool - b: strings.Builder - strings.builder_init(&b) - fmt.sbprintf(&b, "%s-git-remotes", make_temp_path()) - tmp_path := strings.to_string(b) - tmp_file, tmp_err := os.open(tmp_path, os.O_CREATE | os.O_WRONLY | os.O_TRUNC) - if tmp_err != nil { - return remotes - } + b: strings.Builder + strings.builder_init(&b) + fmt.sbprintf(&b, "%s-git-remotes", make_temp_path()) + tmp_path := strings.to_string(b) + tmp_file, tmp_err := os.open(tmp_path, os.O_CREATE | os.O_WRONLY | os.O_TRUNC) + if tmp_err != nil { + return remotes + } - args := []string{"git", "remote", "-v"} - desc := os.Process_Desc{ - command = args, - stdout = tmp_file, - stderr = nil, - working_dir = dir, - } + args := []string{"git", "remote", "-v"} + desc := os.Process_Desc { + command = args, + stdout = tmp_file, + stderr = nil, + working_dir = dir, + } - p, start_err := os.process_start(desc) - os.close(tmp_file) - if start_err != nil { - os.remove(tmp_path) - return remotes - } + p, start_err := os.process_start(desc) + os.close(tmp_file) + if start_err != nil { + os.remove(tmp_path) + return remotes + } - state, wait_err := os.process_wait(p) - if wait_err != nil || state.exit_code != 0 { - os.remove(tmp_path) - return remotes - } + state, wait_err := os.process_wait(p) + if wait_err != nil || state.exit_code != 0 { + os.remove(tmp_path) + return remotes + } - data, read_err := os.read_entire_file_from_path(tmp_path, context.allocator) - os.remove(tmp_path) - if read_err != nil { - return remotes - } + data, read_err := os.read_entire_file_from_path(tmp_path, context.allocator) + os.remove(tmp_path) + if read_err != nil { + return remotes + } - output_str := string(data) - lines := strings.split(output_str, "\n") + output_str := string(data) + lines := strings.split(output_str, "\n") - for &line in lines { - line = strings.trim_space(line) - if len(line) == 0 { - continue - } - parts := strings.fields(line) - if len(parts) >= 2 { - remote_set[parts[1]] = true - } - } + for &line in lines { + line = strings.trim_space(line) + if len(line) == 0 { + continue + } + parts := strings.fields(line) + if len(parts) >= 2 { + remote_set[parts[1]] = true + } + } - for remote, _ in remote_set { - cloned, _ := strings.clone(remote) - append(&remotes, cloned) - } + for remote, _ in remote_set { + cloned, _ := strings.clone(remote) + append(&remotes, cloned) + } - return remotes + return remotes } new_env_file :: proc(path: string) -> (EnvFile, bool) { - abs_path, abs_err := filepath.abs(path) - if abs_err != nil { - fmt.printf("Error getting absolute path: %v\n", abs_err) - return EnvFile{}, false - } - cloned_path, _ := strings.clone(abs_path) + abs_path, abs_err := filepath.abs(path) + if abs_err != nil { + fmt.printf("Error getting absolute path: %v\n", abs_err) + return EnvFile{}, false + } + cloned_path, _ := strings.clone(abs_path) - dir := filepath.dir(cloned_path) + dir := filepath.dir(cloned_path) - remotes := get_git_remotes(dir) + remotes := get_git_remotes(dir) - data, read_err := os.read_entire_file_from_path(cloned_path, context.allocator) - if read_err != nil { - fmt.printf("Error reading file %s: %v\n", cloned_path, read_err) - return EnvFile{}, false - } + data, read_err := os.read_entire_file_from_path(cloned_path, context.allocator) + if read_err != nil { + fmt.printf("Error reading file %s: %v\n", cloned_path, read_err) + return EnvFile{}, false + } - digest := hash.hash_bytes(hash.Algorithm.SHA256, data) - hex_bytes, _ := hex.encode(digest) - sha_str := string(hex_bytes) + digest := hash.hash_bytes(hash.Algorithm.SHA256, data) + hex_bytes, _ := hex.encode(digest) + sha_str := string(hex_bytes) - return EnvFile{ - Path = cloned_path, - Dir = dir, - Remotes = remotes, - Sha256 = sha_str, - contents = string(data), - }, true + return EnvFile { + Path = cloned_path, + Dir = dir, + Remotes = remotes, + Sha256 = sha_str, + contents = string(data), + }, + true } db_insert :: proc(d: ^Db, file: EnvFile) -> bool { - remotes_json, marshal_err := json.marshal(file.Remotes) - if marshal_err != nil { - fmt.printf("Error marshaling remotes: %v\n", marshal_err) - return false - } + remotes_json, marshal_err := json.marshal(file.Remotes) + if marshal_err != nil { + fmt.printf("Error marshaling remotes: %v\n", marshal_err) + return false + } - sql := "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) - if rc != sqlite.OK { - fmt.printf("Error preparing insert: %s\n", sqlite.db_errmsg(d.db)) - return false - } - defer sqlite.finalize(stmt) + sql := "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) + 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) + 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) - rc = sqlite.step(stmt) - if rc != sqlite.DONE { - fmt.printf("Error inserting: %s\n", sqlite.db_errmsg(d.db)) - return false - } + rc = sqlite.step(stmt) + if rc != sqlite.DONE { + fmt.printf("Error inserting: %s\n", sqlite.db_errmsg(d.db)) + return false + } - d.changed = true - return true + d.changed = true + return true } db_fetch :: proc(d: ^Db, path: string) -> (EnvFile, bool) { - sql := "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) - if rc != sqlite.OK { - fmt.printf("Error preparing fetch: %s\n", sqlite.db_errmsg(d.db)) - return EnvFile{}, false - } - defer sqlite.finalize(stmt) + sql := "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) + 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) - rc = sqlite.step(stmt) - if rc == sqlite.DONE { - fmt.printf("No file found with path: %s\n", path) - return EnvFile{}, false - } - if rc != sqlite.ROW { - fmt.printf("Error fetching: %s\n", sqlite.db_errmsg(d.db)) - return EnvFile{}, false - } + rc = sqlite.bind_text(stmt, 1, string_to_cstring(path), -1, nil) + rc = sqlite.step(stmt) + if rc == sqlite.DONE { + fmt.printf("No file found with path: %s\n", path) + return EnvFile{}, false + } + if rc != sqlite.ROW { + fmt.printf("Error fetching: %s\n", sqlite.db_errmsg(d.db)) + 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)) + 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 - if len(remotes_json) > 0 { - json.unmarshal_string(remotes_json, &remotes) - } + remotes: [dynamic]string + if len(remotes_json) > 0 { + json.unmarshal_string(remotes_json, &remotes) + } - cloned_path, _ := strings.clone(file_path) - return EnvFile{ - Path = cloned_path, - Dir = filepath.dir(cloned_path), - Remotes = remotes, - Sha256 = sha, - contents = contents, - }, true + cloned_path, _ := strings.clone(file_path) + return EnvFile { + Path = cloned_path, + Dir = filepath.dir(cloned_path), + Remotes = remotes, + Sha256 = sha, + contents = contents, + }, + true } db_delete :: proc(d: ^Db, path: string) -> bool { - sql := "DELETE FROM envr_env_files WHERE path = ?" - stmt: ^rawptr - rc := sqlite.prepare_v2(d.db, string_to_cstring(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) + sql := "DELETE FROM envr_env_files WHERE path = ?" + stmt: ^rawptr + rc := sqlite.prepare_v2(d.db, string_to_cstring(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) - rc = sqlite.step(stmt) - if rc != sqlite.DONE { - fmt.printf("Error deleting: %s\n", sqlite.db_errmsg(d.db)) - return false - } + rc = sqlite.bind_text(stmt, 1, string_to_cstring(path), -1, nil) + rc = sqlite.step(stmt) + if rc != sqlite.DONE { + fmt.printf("Error deleting: %s\n", sqlite.db_errmsg(d.db)) + return false + } - if sqlite.changes(d.db) == 0 { - fmt.printf("No file found with path: %s\n", path) - return false - } + if sqlite.changes(d.db) == 0 { + fmt.printf("No file found with path: %s\n", path) + return false + } - d.changed = true - return true + d.changed = true + return true } cstring_to_string :: proc(cs: cstring) -> string { - if cs == nil { - return "" - } - s, _ := strings.clone_from_cstring(cs) - return s + if cs == nil { + return "" + } + s, _ := strings.clone_from_cstring(cs) + return s } string_to_cstring :: proc(s: string) -> cstring { - cs, _ := strings.clone_to_cstring(s) - return cs + cs, _ := strings.clone_to_cstring(s) + return cs } db_update_required :: proc(status: SyncResult) -> bool { - s := i32(status) - return (s & (i32(SyncResult.BackedUp) | i32(SyncResult.DirUpdated))) != 0 + s := i32(status) + return (s & (i32(SyncResult.BackedUp) | i32(SyncResult.DirUpdated))) != 0 } shares_remote :: proc(f: ^EnvFile, remotes: []string) -> bool { - remote_set: map[string]bool - for r in f.Remotes { - remote_set[r] = true - } - for r in remotes { - if r in remote_set { - return true - } - } - return false + for r1 in f.Remotes { + for r2 in remotes { + if r1 == r2 { + return true + } + } + } + return false } update_dir :: proc(f: ^EnvFile, new_dir: string) { - f.Dir = new_dir - base := filepath.base(f.Path) - new_path, _ := strings.concatenate({new_dir, "/", base}) - f.Path = new_path - f.Remotes = get_git_remotes(new_dir) + f.Dir = new_dir + base := filepath.base(f.Path) + new_path, _ := strings.concatenate({new_dir, "/", base}) + f.Path = new_path + f.Remotes = get_git_remotes(new_dir) } find_moved_dirs :: proc(d: ^Db, f: ^EnvFile) -> ([dynamic]string, bool) { - feats := check_features() - if .Fd not_in feats || .Git not_in feats { - fmt.println("Error: fd and git are required for moved dir detection") - return {}, false - } + feats := check_features() + if .Fd not_in feats || .Git not_in feats { + fmt.println("Error: fd and git are required for moved dir detection") + return {}, false + } - roots, roots_ok := find_git_roots(d.cfg) - if !roots_ok { - return {}, false - } + roots, roots_ok := find_git_roots(d.cfg) + if !roots_ok { + return {}, false + } - moved: [dynamic]string - for root in roots { - remotes := get_git_remotes(root) - if shares_remote(f, remotes[:]) { - cloned, _ := strings.clone(root) - append(&moved, cloned) - } - } - return moved, true + moved: [dynamic]string + for root in roots { + remotes := get_git_remotes(root) + if shares_remote(f, remotes[:]) { + cloned, _ := strings.clone(root) + append(&moved, cloned) + } + } + return moved, true } env_file_backup :: proc(f: ^EnvFile) -> bool { - data, read_err := os.read_entire_file_from_path(f.Path, context.allocator) - if read_err != nil { - fmt.printf("Error reading file %s: %v\n", f.Path, read_err) - return false - } + data, read_err := os.read_entire_file_from_path(f.Path, context.allocator) + if read_err != nil { + fmt.printf("Error reading file %s: %v\n", f.Path, read_err) + return false + } - f.contents = string(data) - digest := hash.hash_bytes(hash.Algorithm.SHA256, data) - hex_bytes, _ := hex.encode(digest) - f.Sha256 = string(hex_bytes) - return true + f.contents = string(data) + digest := hash.hash_bytes(hash.Algorithm.SHA256, data) + hex_bytes, _ := hex.encode(digest) + f.Sha256 = string(hex_bytes) + return true } env_file_sync :: proc(f: ^EnvFile, dir: SyncDirection, d: ^Db) -> (SyncResult, string) { - result: SyncResult = .Noop - err_msg: string + result: SyncResult = .Noop + err_msg: string - _, stat_err := os.stat(f.Dir, context.allocator) - if stat_err != nil { - moved_dirs: [dynamic]string + _, stat_err := os.stat(f.Dir, context.allocator) + if stat_err != nil { + moved_dirs: [dynamic]string - if d != nil { - dirs, dirs_ok := find_moved_dirs(d, f) - if !dirs_ok { - return .Error, "failed to find moved dirs" - } - moved_dirs = dirs - } + if d != nil { + dirs, dirs_ok := find_moved_dirs(d, f) + if !dirs_ok { + return .Error, "failed to find moved dirs" + } + moved_dirs = dirs + } - if len(moved_dirs) == 0 { - return .Error, "directory missing" - } else if len(moved_dirs) == 1 { - update_dir(f, moved_dirs[0]) - result = .DirUpdated - } else { - return .Error, "multiple directories found" - } - } + if len(moved_dirs) == 0 { + return .Error, "directory missing" + } else if len(moved_dirs) == 1 { + update_dir(f, moved_dirs[0]) + result = .DirUpdated + } else { + return .Error, "multiple directories found" + } + } - _, file_stat_err := os.stat(f.Path, context.allocator) - if file_stat_err != nil { - write_err := os.write_entire_file(f.Path, f.contents) - if write_err != nil { - msg, _ := strings.concatenate({"failed to write file: ", fmt.tprintf("%v", write_err)}) - return .Error, msg - } + _, file_stat_err := os.stat(f.Path, context.allocator) + if file_stat_err != nil { + write_err := os.write_entire_file(f.Path, f.contents) + if write_err != nil { + msg, _ := strings.concatenate({"failed to write file: ", fmt.tprintf("%v", write_err)}) + return .Error, msg + } - s := i32(result) | i32(SyncResult.Restored) - return SyncResult(s), "" - } + s := i32(result) | i32(SyncResult.Restored) + return SyncResult(s), "" + } - data, read_err := os.read_entire_file_from_path(f.Path, context.allocator) - if read_err != nil { - msg, _ := strings.concatenate({"failed to read file for SHA comparison: ", fmt.tprintf("%v", read_err)}) - return .Error, msg - } + data, read_err := os.read_entire_file_from_path(f.Path, context.allocator) + if read_err != nil { + msg, _ := strings.concatenate( + {"failed to read file for SHA comparison: ", fmt.tprintf("%v", read_err)}, + ) + return .Error, msg + } - digest := hash.hash_bytes(hash.Algorithm.SHA256, data) - hex_bytes, _ := hex.encode(digest) - current_sha := string(hex_bytes) + digest := hash.hash_bytes(hash.Algorithm.SHA256, data) + hex_bytes, _ := hex.encode(digest) + current_sha := string(hex_bytes) - if current_sha == f.Sha256 { - return result, "" - } + if current_sha == f.Sha256 { + return result, "" + } - switch dir { - case .TrustDatabase: - write_err := os.write_entire_file(f.Path, f.contents) - if write_err != nil { - msg, _ := strings.concatenate({"failed to write file: ", fmt.tprintf("%v", write_err)}) - return .Error, msg - } - s := i32(result) | i32(SyncResult.Restored) - return SyncResult(s), "" - case .TrustFilesystem: - if !env_file_backup(f) { - return .Error, "failed to backup file" - } - return .BackedUp, "" - } + switch dir { + case .TrustDatabase: + write_err := os.write_entire_file(f.Path, f.contents) + if write_err != nil { + msg, _ := strings.concatenate({"failed to write file: ", fmt.tprintf("%v", write_err)}) + return .Error, msg + } + s := i32(result) | i32(SyncResult.Restored) + return SyncResult(s), "" + case .TrustFilesystem: + if !env_file_backup(f) { + return .Error, "failed to backup file" + } + return .BackedUp, "" + } - return result, "" + return result, "" } db_sync :: proc(d: ^Db, f: ^EnvFile) -> (SyncResult, string) { - return env_file_sync(f, .TrustFilesystem, d) + return env_file_sync(f, .TrustFilesystem, d) } + diff --git a/db_test.odin b/db_test.odin new file mode 100644 index 0000000..6390e17 --- /dev/null +++ b/db_test.odin @@ -0,0 +1,90 @@ +package main + +import "core:testing" + +@(test) +test_db_update_required_noop :: proc(t: ^testing.T) { + testing.expect(t, !db_update_required(.Noop), "Noop should not require update") +} + +@(test) +test_db_update_required_backed_up :: proc(t: ^testing.T) { + testing.expect(t, db_update_required(.BackedUp), "BackedUp should require update") +} + +@(test) +test_db_update_required_dir_updated :: proc(t: ^testing.T) { + testing.expect(t, db_update_required(.DirUpdated), "DirUpdated should require update") +} + +@(test) +test_db_update_required_restored :: proc(t: ^testing.T) { + testing.expect(t, !db_update_required(.Restored), "Restored alone should not require update") +} + +@(test) +test_db_update_required_error :: proc(t: ^testing.T) { + testing.expect(t, !db_update_required(.Error), "Error alone should not require update") +} + +@(test) +test_db_update_required_combined :: proc(t: ^testing.T) { + s := i32(SyncResult.DirUpdated) | i32(SyncResult.Restored) + combined := SyncResult(s) + testing.expect(t, db_update_required(combined), "DirUpdated|Restored should require update") +} + +@(test) +test_shares_remote_overlap :: proc(t: ^testing.T) { + f := EnvFile { + Remotes = make([dynamic]string, 2, context.temp_allocator), + } + append(&f.Remotes, "git@github.com:user/repo.git") + append(&f.Remotes, "git@gitlab.com:user/repo.git") + + remotes := []string{"git@github.com:user/repo.git"} + testing.expect(t, shares_remote(&f, remotes), "should share remote") +} + +@(test) +test_shares_remote_no_overlap :: proc(t: ^testing.T) { + f := EnvFile { + Remotes = make([dynamic]string, 1, context.temp_allocator), + } + append(&f.Remotes, "git@github.com:user/repo.git") + + remotes := []string{"git@github.com:other/repo.git"} + testing.expect(t, !shares_remote(&f, remotes), "should not share remote") +} + +@(test) +test_shares_remote_empty_file_remotes :: proc(t: ^testing.T) { + f := EnvFile { + Remotes = make([dynamic]string, 0, context.temp_allocator), + } + + remotes := []string{"git@github.com:user/repo.git"} + testing.expect(t, !shares_remote(&f, remotes), "empty file remotes should not share") +} + +@(test) +test_shares_remote_empty_check_remotes :: proc(t: ^testing.T) { + f := EnvFile { + Remotes = make([dynamic]string, 1, context.temp_allocator), + } + append(&f.Remotes, "git@github.com:user/repo.git") + + remotes: []string + testing.expect(t, !shares_remote(&f, remotes), "empty check remotes should not share") +} + +@(test) +test_shares_remote_both_empty :: proc(t: ^testing.T) { + f := EnvFile { + Remotes = make([dynamic]string, 0), + } + + remotes: []string + testing.expect(t, !shares_remote(&f, remotes), "both empty should not share") +} + diff --git a/scan_test.odin b/scan_test.odin index 535d2df..8bc3624 100644 --- a/scan_test.odin +++ b/scan_test.odin @@ -37,7 +37,7 @@ test_scan_path_finds_gitignored_env_files :: proc(t: ^testing.T) { _ = os.write_entire_file(fmt.tprintf("%s/config.yaml", base), "key: value") cfg := Config { - ScanConfig = ScanConfig{Matcher = "\\.env", Exclude = []string{}, Include = []string{}}, + ScanConfig = ScanConfig{Matcher = "\\.env"}, } results, ok := scan_path(base, cfg) @@ -76,7 +76,7 @@ test_scan_path_empty_dir :: proc(t: ^testing.T) { defer os.remove_all(base) cfg := Config { - ScanConfig = ScanConfig{Matcher = "\\.env", Exclude = []string{}, Include = []string{}}, + ScanConfig = ScanConfig{Matcher = "\\.env"}, } results, ok := scan_path(base, cfg)