test: Added tests.

This commit is contained in:
2026-06-12 14:19:58 -04:00
parent 7d16dae4f4
commit 67f735a654
6 changed files with 696 additions and 475 deletions

View File

@@ -1,3 +1,5 @@
#+feature dynamic-literals
package main
import "core:fmt" import "core:fmt"
import "core:strings" import "core:strings"
@@ -140,3 +142,50 @@ test_command_help_version :: proc(t: ^testing.T) {
} }
@(test) @(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")
}

View File

@@ -13,17 +13,17 @@ SshKeyPair :: struct {
ScanConfig :: struct { ScanConfig :: struct {
Matcher: string `json:"matcher"`, Matcher: string `json:"matcher"`,
Exclude: []string `json:"exclude"`, Exclude: [dynamic]string `json:"exclude"`,
Include: []string `json:"include"`, Include: [dynamic]string `json:"include"`,
} }
Config :: struct { Config :: struct {
Keys: []SshKeyPair `json:"keys"`, Keys: [dynamic]SshKeyPair `json:"keys"`,
ScanConfig: ScanConfig `json:"scan"`, ScanConfig: ScanConfig `json:"scan"`,
} }
load_config :: proc() -> (Config, bool) { 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 { if home_err != nil {
fmt.printf("Error getting home dir: %v\n", home_err) fmt.printf("Error getting home dir: %v\n", home_err)
return Config{}, false return Config{}, false
@@ -49,6 +49,12 @@ load_config :: proc() -> (Config, bool) {
return cfg, true return cfg, true
} }
delete_config :: proc(cfg: Config) {
delete(cfg.Keys)
delete(cfg.ScanConfig.Exclude)
delete(cfg.ScanConfig.Include)
}
envr_dir :: proc() -> string { envr_dir :: proc() -> string {
home, _ := os.user_home_dir(context.allocator) home, _ := os.user_home_dir(context.allocator)
dir, _ := filepath.join([]string{home, ".envr"}) 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 { new_config :: proc(private_key_paths: []string) -> Config {
keys := make([dynamic]SshKeyPair, 0, len(private_key_paths)) keys := make([dynamic]SshKeyPair, 0, len(private_key_paths))
for priv in 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}) append(&keys, SshKeyPair{Private = priv, Public = pub})
} }
@@ -122,11 +129,11 @@ new_config :: proc(private_key_paths: []string) -> Config {
scan_cfg := ScanConfig { scan_cfg := ScanConfig {
Matcher = "\\.env", Matcher = "\\.env",
Exclude = exclude[:], Exclude = exclude,
Include = include[:], 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 { save_config :: proc(cfg: Config, force: bool = false) -> bool {

63
config_test.odin Normal file
View File

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

40
db.odin
View File

@@ -91,7 +91,7 @@ db_close :: proc(d: ^Db) {
return return
} }
db_encrypt_file(tmp_path, d.cfg.Keys) db_encrypt_file(tmp_path, d.cfg.Keys[:])
os.remove(tmp_path) os.remove(tmp_path)
d.changed = false d.changed = false
} }
@@ -128,13 +128,16 @@ db_list :: proc(d: ^Db) -> (results: [dynamic]EnvFile, ok: bool) {
json.unmarshal_string(remotes_json, &remotes) json.unmarshal_string(remotes_json, &remotes)
} }
append(&results, EnvFile{ append(
&results,
EnvFile {
Path = path, Path = path,
Dir = filepath.dir(path), Dir = filepath.dir(path),
Remotes = remotes, Remotes = remotes,
Sha256 = sha, Sha256 = sha,
contents = contents, contents = contents,
}) },
)
} }
sqlite.finalize(stmt) sqlite.finalize(stmt)
@@ -159,7 +162,7 @@ db_restore_from_age :: proc(db: ^rawptr, cfg: Config) -> bool {
tmp_path := make_temp_path() tmp_path := make_temp_path()
defer os.remove(tmp_path) defer os.remove(tmp_path)
if !db_decrypt_to_file(tmp_path, cfg.Keys) { if !db_decrypt_to_file(tmp_path, cfg.Keys[:]) {
return false return false
} }
@@ -269,7 +272,13 @@ db_attach_and_copy :: proc(mem_db: ^rawptr, src_path: string) -> bool {
return false return false
} }
rc = sqlite.db_exec(mem_db, "INSERT INTO main.envr_env_files SELECT * FROM source.envr_env_files", nil, nil, nil) 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 { if rc != sqlite.OK {
fmt.printf("Error copying data: %s\n", sqlite.db_errmsg(mem_db)) fmt.printf("Error copying data: %s\n", sqlite.db_errmsg(mem_db))
sqlite.db_exec(mem_db, "DETACH DATABASE source", nil, nil, nil) sqlite.db_exec(mem_db, "DETACH DATABASE source", nil, nil, nil)
@@ -370,7 +379,8 @@ new_env_file :: proc(path: string) -> (EnvFile, bool) {
Remotes = remotes, Remotes = remotes,
Sha256 = sha_str, Sha256 = sha_str,
contents = string(data), contents = string(data),
}, true },
true
} }
db_insert :: proc(d: ^Db, file: EnvFile) -> bool { db_insert :: proc(d: ^Db, file: EnvFile) -> bool {
@@ -442,7 +452,8 @@ db_fetch :: proc(d: ^Db, path: string) -> (EnvFile, bool) {
Remotes = remotes, Remotes = remotes,
Sha256 = sha, Sha256 = sha,
contents = contents, contents = contents,
}, true },
true
} }
db_delete :: proc(d: ^Db, path: string) -> bool { db_delete :: proc(d: ^Db, path: string) -> bool {
@@ -490,15 +501,13 @@ db_update_required :: proc(status: SyncResult) -> bool {
} }
shares_remote :: proc(f: ^EnvFile, remotes: []string) -> bool { shares_remote :: proc(f: ^EnvFile, remotes: []string) -> bool {
remote_set: map[string]bool for r1 in f.Remotes {
for r in f.Remotes { for r2 in remotes {
remote_set[r] = true if r1 == r2 {
}
for r in remotes {
if r in remote_set {
return true return true
} }
} }
}
return false return false
} }
@@ -587,7 +596,9 @@ env_file_sync :: proc(f: ^EnvFile, dir: SyncDirection, d: ^Db) -> (SyncResult, s
data, read_err := os.read_entire_file_from_path(f.Path, context.allocator) data, read_err := os.read_entire_file_from_path(f.Path, context.allocator)
if read_err != nil { if read_err != nil {
msg, _ := strings.concatenate({"failed to read file for SHA comparison: ", fmt.tprintf("%v", read_err)}) msg, _ := strings.concatenate(
{"failed to read file for SHA comparison: ", fmt.tprintf("%v", read_err)},
)
return .Error, msg return .Error, msg
} }
@@ -621,3 +632,4 @@ env_file_sync :: proc(f: ^EnvFile, dir: SyncDirection, d: ^Db) -> (SyncResult, s
db_sync :: proc(d: ^Db, f: ^EnvFile) -> (SyncResult, string) { db_sync :: proc(d: ^Db, f: ^EnvFile) -> (SyncResult, string) {
return env_file_sync(f, .TrustFilesystem, d) return env_file_sync(f, .TrustFilesystem, d)
} }

90
db_test.odin Normal file
View File

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

View File

@@ -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") _ = os.write_entire_file(fmt.tprintf("%s/config.yaml", base), "key: value")
cfg := Config { cfg := Config {
ScanConfig = ScanConfig{Matcher = "\\.env", Exclude = []string{}, Include = []string{}}, ScanConfig = ScanConfig{Matcher = "\\.env"},
} }
results, ok := scan_path(base, cfg) results, ok := scan_path(base, cfg)
@@ -76,7 +76,7 @@ test_scan_path_empty_dir :: proc(t: ^testing.T) {
defer os.remove_all(base) defer os.remove_all(base)
cfg := Config { cfg := Config {
ScanConfig = ScanConfig{Matcher = "\\.env", Exclude = []string{}, Include = []string{}}, ScanConfig = ScanConfig{Matcher = "\\.env"},
} }
results, ok := scan_path(base, cfg) results, ok := scan_path(base, cfg)