1 Commits

Author SHA1 Message Date
b66e905482 refactor: Gave db its own allocator. 2026-06-18 13:10:13 -04:00
9 changed files with 311 additions and 348 deletions

View File

@@ -4,8 +4,6 @@
27. Commands are still leaking. 27. Commands are still leaking.
28. **db.odin** — Inconsistencies in how struct vs sqlite are named.
2. Generate md and man pages again. 2. Generate md and man pages again.
3. **db.odin:324-327** — Map iteration (`remote_set`) is non-deterministic. Same file can produce different JSON on each backup, causing spurious DB diffs. Sort remotes before storing. 3. **db.odin:324-327** — Map iteration (`remote_set`) is non-deterministic. Same file can produce different JSON on each backup, causing spurious DB diffs. Sort remotes before storing.

View File

@@ -12,7 +12,8 @@ cmd_edit_config :: proc(cmd: ^Command) {
config_path := cmd.config_path config_path := cmd.config_path
if !os.exists(config_path) { _, stat_err := os.stat(config_path, context.allocator)
if stat_err != nil {
fmt.wprintf( fmt.wprintf(
cmd.err, cmd.err,
"Config file does not exist at %s. Run 'envr init' first.\n", "Config file does not exist at %s. Run 'envr init' first.\n",

View File

@@ -12,7 +12,7 @@ cmd_scan :: proc(cmd: ^Command) {
} }
defer db_close(&db) defer db_close(&db)
search_dirs := search_paths(db.cfg, context.temp_allocator) search_dirs := search_paths(db.cfg)
if len(search_dirs) == 0 { if len(search_dirs) == 0 {
fmt.wprintln( fmt.wprintln(
cmd.err, cmd.err,

View File

@@ -24,42 +24,63 @@ cmd_sync :: proc(cmd: ^Command) {
if !list_ok { if !list_ok {
return return
} }
defer delete(files)
results := make([]SyncEntry, len(files), context.temp_allocator) // TODO: Set sane default size
results: [dynamic]SyncEntry
defer delete(results)
for &file, i in files { for &file in files {
result, err := db_sync(&db, &file) old_path: string
old_path, _ = strings.clone(file.Path, context.temp_allocator)
result, err_msg := db_sync(&db, &file)
status: string status: string
if err != .None { is_dir_updated := .DirUpdated in result
status = sync_error_message(err)
} else if .BackedUp in result { switch {
status = .DirUpdated in result ? "Moved & Backed Up" : "Backed Up" case .Error in result:
} else if .Restored in result { if len(err_msg) > 0 {
status = .DirUpdated in result ? "Moved & Restored" : "Restored" status = err_msg
} else if .DirUpdated in result { } else {
status = "error"
}
case .BackedUp in result:
status = "Backed Up"
case .Restored in result:
status = "Restored"
case .DirUpdated in result:
status = "Moved" status = "Moved"
} else { case:
status = "OK" status = "OK"
} }
// TODO: Handle errors if is_dir_updated {
path_str, _ := strings.clone(file.Path, context.temp_allocator) if !db_delete(&db, old_path) {
status_str, _ := strings.clone(status, context.temp_allocator) return
results[i] = SyncEntry { }
Path = path_str,
Status = status_str,
} }
if db_update_required(result) {
if !db_insert(&db, file) {
return
}
}
path_str, _ := strings.clone(file.Path)
status_str, _ := strings.clone(status)
append(&results, SyncEntry{Path = path_str, Status = status_str})
} }
if terminal.is_terminal(os.stdout) { if terminal.is_terminal(os.stdout) {
headers := []string{"File", "Status"} headers := []string{"File", "Status"}
// TODO: Use [2]string instead of slice here table_rows := make([dynamic][]string, 0, len(results))
table_rows := make([dynamic][]string, 0, len(results), context.temp_allocator)
for res in results { for res in results {
row_slice := [2]string{res.Path, res.Status} row_slice := make([]string, 2)
append(&table_rows, row_slice[:]) row_slice[0] = res.Path
row_slice[1] = res.Status
append(&table_rows, row_slice)
} }
render_table(cmd.out, headers, table_rows[:]) render_table(cmd.out, headers, table_rows[:])
@@ -73,23 +94,3 @@ cmd_sync :: proc(cmd: ^Command) {
} }
} }
sync_error_message :: proc(e: SyncError) -> string {
switch e {
case .None:
return ""
case .DirMissing:
return "directory missing"
case .MultipleDirs:
return "multiple directories found"
case .GitRootFailed:
return "failed to find git roots"
case .WriteFailed:
return "failed to write file"
case .ReadFailed:
return "failed to read file"
case .DbFailed:
return "failed to update database"
}
return "unknown error"
}

View File

@@ -187,40 +187,32 @@ find_ssh_private_keys :: proc() -> (keys: [dynamic]string, ok: bool) {
return return
} }
find_git_roots :: proc( find_git_roots :: proc(cfg: Config) -> (roots: [dynamic]string, ok: bool) {
cfg: Config, paths := search_paths(cfg)
allocator := context.temp_allocator,
) -> (
roots: [dynamic]string,
ok: bool,
) {
paths := search_paths(cfg, allocator)
// TODO: Pass allocator to findr
// findr.find_repos(paths[:], &roots, os.get_processor_core_count(), allocator)
findr.find_repos(paths[:], &roots, os.get_processor_core_count()) findr.find_repos(paths[:], &roots, os.get_processor_core_count())
ok = true ok = true
return return
} }
search_paths :: proc(cfg: Config, allocator := context.allocator) -> [dynamic]string { search_paths :: proc(cfg: Config) -> (paths: [dynamic]string) {
// TODO: Is this okay?
// TODO: handle error // TODO: handle error
home, _ := os.user_home_dir(context.temp_allocator) home, _ := os.user_home_dir(context.temp_allocator)
paths, _ := new_clone(cfg.ScanConfig.Include, allocator) for include in cfg.ScanConfig.Include {
for &include in paths {
// TODO: Do we need to manually expand ~/ in odin? // TODO: Do we need to manually expand ~/ in odin?
expanded, _ := strings.replace(include, "~", home, 1, allocator) expanded, _ := strings.replace(include, "~", home, 1)
if filepath.is_abs(expanded) { if filepath.is_abs(expanded) {
include = expanded append(&paths, expanded)
} else { } else {
resolved, err := filepath.abs(expanded, allocator) defer delete(expanded)
resolved, err := filepath.abs(expanded)
if err == nil { if err == nil {
include = resolved append(&paths, resolved)
} }
} }
} }
return paths^ return
} }
envr_dir :: proc(config_path: string) -> string { envr_dir :: proc(config_path: string) -> string {

View File

@@ -187,10 +187,14 @@ test_search_paths_expands_tilde :: proc(t: ^testing.T) {
cfg := Config { cfg := Config {
ScanConfig = ScanConfig{Include = make([dynamic]string, 0, 1)}, ScanConfig = ScanConfig{Include = make([dynamic]string, 0, 1)},
} }
append(&cfg.ScanConfig.Include, "~")
defer delete(cfg.ScanConfig.Include) defer delete(cfg.ScanConfig.Include)
append(&cfg.ScanConfig.Include, "~")
paths := search_paths(cfg, context.temp_allocator) paths := search_paths(cfg)
defer delete(paths)
for path in paths {
defer delete(path)
}
testing.expect(t, len(paths) == 1, "should have 1 path") testing.expect(t, len(paths) == 1, "should have 1 path")
if len(paths) > 0 { if len(paths) > 0 {

279
db.odin
View File

@@ -13,21 +13,18 @@ import "core:strings"
import "sqlite" import "sqlite"
SyncFlagEnum :: enum { SyncFlagEnum :: enum {
Noop,
DirUpdated, DirUpdated,
Restored, Restored,
BackedUp, BackedUp,
Error,
} }
SyncFlag :: bit_set[SyncFlagEnum] SyncFlag :: bit_set[SyncFlagEnum]
SyncError :: enum { SyncDirection :: enum {
None, TrustDatabase,
DirMissing, TrustFilesystem,
MultipleDirs,
GitRootFailed,
WriteFailed,
ReadFailed,
DbFailed,
} }
Db :: struct { Db :: struct {
@@ -46,7 +43,6 @@ EnvFile :: struct {
contents: string, contents: string,
} }
@(deprecated = "call db_close to clean up EnvFiles")
delete_envfile :: proc(f: ^EnvFile) { delete_envfile :: proc(f: ^EnvFile) {
delete(f.Path) delete(f.Path)
for &remote in f.Remotes { for &remote in f.Remotes {
@@ -57,16 +53,23 @@ delete_envfile :: proc(f: ^EnvFile) {
delete(f.contents) delete(f.contents)
} }
db_open :: proc(cfg_path: string) -> (database: Db, ok: bool) { db_open :: proc(cfg_path: string) -> (Db, bool) {
database = db_init() or_return database, ok := db_init()
database.cfg = load_config(cfg_path, db_allocator(&database)) or_return if !ok {
return database, ok
}
database.cfg, ok = load_config(cfg_path, db_allocator(&database))
if !ok {
return database, ok
}
// TODO: Use different allocators? // TODO: Use different allocators?
data_path := data_path(database.cfg.config_path, context.temp_allocator) data_path := data_path(database.cfg.config_path, context.temp_allocator)
if os.exists(data_path) { if os.exists(data_path) {
if ok = db_restore_from_encrypted(&database, data_path); !ok { if ok = db_restore_from_encrypted(&database, data_path); !ok {
sqlite.db_close(database.db) sqlite.db_close(database.db)
return database, false return database, ok
} }
} else { } else {
// DB was created // DB was created
@@ -100,18 +103,21 @@ db_init :: proc() -> (database: Db, ok: bool) {
return database, true return database, true
} }
// TODO: Should allocator live on the db?
db_allocator :: proc(db: ^Db) -> mem.Allocator { db_allocator :: proc(db: ^Db) -> mem.Allocator {
return mem.dynamic_arena_allocator(&db.arena) return mem.dynamic_arena_allocator(&db.arena)
} }
// TODO: use db allocator?
db_restore_from_encrypted :: proc(db: ^Db, data_path: string) -> bool { db_restore_from_encrypted :: proc(db: ^Db, data_path: string) -> bool {
encrypted_data, read_err := os.read_entire_file_from_path(data_path, context.temp_allocator) encrypted_data, read_err := os.read_entire_file_from_path(data_path, context.allocator)
defer delete(encrypted_data)
if read_err != nil { if read_err != nil {
fmt.printf("Error reading encrypted database: %v\n", read_err) fmt.printf("Error reading encrypted database: %v\n", read_err)
return false return false
} }
// TODO: Use context.temp_allocator // TODO: allow allocator selection?
plaintext, dec_ok := decrypt(encrypted_data, db.cfg.Keys[:]) plaintext, dec_ok := decrypt(encrypted_data, db.cfg.Keys[:])
if !dec_ok { if !dec_ok {
fmt.println("Error: decryption failed") fmt.println("Error: decryption failed")
@@ -144,14 +150,13 @@ db_restore_from_encrypted :: proc(db: ^Db, data_path: string) -> bool {
return true return true
} }
// TODO: Return error enum?
db_close :: proc(d: ^Db) { db_close :: proc(d: ^Db) {
allocator := db_allocator(d) allocator := db_allocator(d)
defer { defer {
sqlite.db_close(d.db) sqlite.db_close(d.db)
delete_config(&d.cfg, allocator) delete_config(&d.cfg, allocator)
mem.dynamic_arena_destroy(&d.arena) mem.dynamic_arena_destroy(&d.arena)
} }
@@ -172,6 +177,7 @@ db_close :: proc(d: ^Db) {
sqlite_data := data[:sz] sqlite_data := data[:sz]
// TODO: PAss allocator chain // TODO: PAss allocator chain
// FIXME Warning gets printed in tests.
encrypted, enc_ok := encrypt(sqlite_data, d.cfg.Keys[:]) encrypted, enc_ok := encrypt(sqlite_data, d.cfg.Keys[:])
if !enc_ok { if !enc_ok {
fmt.println("Error: encryption failed") fmt.println("Error: encryption failed")
@@ -210,6 +216,7 @@ db_list :: proc(d: ^Db) -> ([]EnvFile, bool) {
defer sqlite.finalize(stmt) defer sqlite.finalize(stmt)
allocator := db_allocator(d) allocator := db_allocator(d)
// TODO: Is 10 a good size?
results := make([dynamic]EnvFile, 0, 10, allocator) results := make([dynamic]EnvFile, 0, 10, allocator)
for { for {
@@ -217,9 +224,9 @@ db_list :: proc(d: ^Db) -> ([]EnvFile, bool) {
if rc == sqlite.DONE { if rc == sqlite.DONE {
break break
} }
if rc != sqlite.ROW { #no_bounds_check if rc != sqlite.ROW {
fmt.printf("Error stepping query: %s\n", sqlite.db_errmsg(d.db)) fmt.printf("Error stepping query: %s\n", sqlite.db_errmsg(d.db))
#no_bounds_check return results[:], false return results[:], false
} }
remotes_json := string(sqlite.column_text(stmt, 1)) remotes_json := string(sqlite.column_text(stmt, 1))
@@ -244,13 +251,14 @@ db_list :: proc(d: ^Db) -> ([]EnvFile, bool) {
#no_bounds_check return results[:], true #no_bounds_check return results[:], true
} }
// TODO: Should we use context.temp_allocator for proc scoped lifetimes? // FIXME: Needs to use the allocator
db_insert :: proc(d: ^Db, file: EnvFile) -> bool { db_insert :: proc(d: ^Db, file: EnvFile) -> bool {
remotes_json, marshal_err := json.marshal(file.Remotes, allocator = context.temp_allocator) remotes_json, marshal_err := json.marshal(file.Remotes)
if marshal_err != nil { if marshal_err != nil {
fmt.printf("Error marshaling remotes: %v\n", marshal_err) fmt.printf("Error marshaling remotes: %v\n", marshal_err)
return false return false
} }
defer delete(remotes_json)
sql: cstring = sql: cstring =
"INSERT OR REPLACE INTO " + "INSERT OR REPLACE INTO " +
@@ -317,10 +325,8 @@ db_fetch :: proc(d: ^Db, path: string) -> (EnvFile, bool) {
} }
defer sqlite.finalize(stmt) defer sqlite.finalize(stmt)
allocator := db_allocator(d) // TODO: Use standard allocator + delete here?
cpath := to_cstring(path, context.temp_allocator)
cpath := to_cstring(path, allocator)
defer delete(cpath, allocator)
rc = sqlite.bind_text(stmt, 1, cpath, -1, nil) rc = sqlite.bind_text(stmt, 1, cpath, -1, nil)
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error binding path: %s\n", sqlite.db_errmsg(d.db)) fmt.printf("Error binding path: %s\n", sqlite.db_errmsg(d.db))
@@ -336,6 +342,8 @@ db_fetch :: proc(d: ^Db, path: string) -> (EnvFile, bool) {
return EnvFile{}, false return EnvFile{}, false
} }
allocator := db_allocator(d)
remotes_json := string(sqlite.column_text(stmt, 1)) remotes_json := string(sqlite.column_text(stmt, 1))
remotes: [dynamic]string = --- remotes: [dynamic]string = ---
if len(remotes_json) > 0 { if len(remotes_json) > 0 {
@@ -364,6 +372,7 @@ db_delete :: proc(d: ^Db, path: string) -> bool {
} }
defer sqlite.finalize(stmt) defer sqlite.finalize(stmt)
// TODO: Use db allocator here?
cpath := to_cstring(path) cpath := to_cstring(path)
defer delete(cpath) defer delete(cpath)
rc = sqlite.bind_text(stmt, 1, cpath, -1, nil) rc = sqlite.bind_text(stmt, 1, cpath, -1, nil)
@@ -395,8 +404,7 @@ new_env_file :: proc(path: string) -> (EnvFile, bool) {
dir := filepath.dir(abs_path) dir := filepath.dir(abs_path)
// TODO: Should we use the db allocator here? remotes := get_git_remotes(dir)
remotes := get_git_remotes(dir, context.allocator)
data, read_err := os.read_entire_file_from_path(abs_path, context.allocator) data, read_err := os.read_entire_file_from_path(abs_path, context.allocator)
defer delete(data) defer delete(data)
@@ -419,101 +427,131 @@ new_env_file :: proc(path: string) -> (EnvFile, bool) {
true true
} }
// Reconciles `f` with the filesystem and persists changes to the database. db_sync :: proc(d: ^Db, f: ^EnvFile) -> (SyncFlag, string) {
db_sync :: proc(d: ^Db, f: ^EnvFile) -> (SyncFlag, SyncError) { return env_file_sync(f, .TrustFilesystem, d)
allocator := db_allocator(d) }
result: SyncFlag = {}
old_path := f.Path
if !os.exists(f.Dir) { // If SyncFlag is .BackedUp, Caller is responsible for calling delete on f.contents and f.Sha256
moved, err := try_move_dir(d, f, allocator) // TODO: Should this use the db allocator?
if !moved { env_file_sync :: proc(f: ^EnvFile, dir: SyncDirection, d: ^Db) -> (SyncFlag, string) {
return {}, err result: SyncFlag = {}
_, 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 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"
} }
result += {.DirUpdated}
} }
if !os.exists(f.Path) { _, file_stat_err := os.stat(f.Path, context.allocator)
if file_stat_err != nil {
write_err := os.write_entire_file(f.Path, f.contents) write_err := os.write_entire_file(f.Path, f.contents)
if write_err != nil { if write_err != nil {
fmt.eprintf("db_sync: failed to write %s: %v\n", f.Path, write_err) msg, _ := strings.concatenate({"failed to write file: ", fmt.tprintf("%v", write_err)})
return result, .WriteFailed return {.Error}, msg
} }
if !db_persist(d, f, old_path) { return result + {.Restored}, ""
return result, .DbFailed
}
return result + {.Restored}, .None
} }
data, read_err := os.read_entire_file_from_path(f.Path, allocator) data, read_err := os.read_entire_file_from_path(f.Path, context.allocator)
if read_err != nil { if read_err != nil {
fmt.eprintf("db_sync: failed to read %s: %v\n", f.Path, read_err) msg, _ := strings.concatenate(
return result, .ReadFailed {"failed to read file for SHA comparison: ", fmt.tprintf("%v", read_err)},
)
return {.Error}, msg
} }
digest := hash.hash_bytes(hash.Algorithm.SHA256, data, context.temp_allocator) digest := hash.hash_bytes(hash.Algorithm.SHA256, data)
hex_bytes, hex_err := hex.encode(digest, allocator) // TODO: Handle error
if hex_err != nil { hex_bytes, _ := hex.encode(digest)
fmt.eprintf("db_sync: failed to encode hash for %s: %v\n", f.Path, hex_err)
return result, .ReadFailed
}
current_sha := string(hex_bytes) current_sha := string(hex_bytes)
if current_sha == f.Sha256 { if current_sha == f.Sha256 {
if !db_persist(d, f, old_path) { return result, ""
return result, .DbFailed }
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
} }
return result, .None return result + {.Restored}, ""
case .TrustFilesystem:
if !env_file_backup(f) {
return {.Error}, "failed to backup file"
}
return result + {.BackedUp}, ""
}
return result, ""
}
// TODO: Should this use the db allocator?
find_moved_dirs :: proc(d: ^Db, f: ^EnvFile) -> ([dynamic]string, bool) {
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
}
// TODO: Should this use the db allocator?
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)
}
// Loads the contents of the the file at f.Path into f.contents
//
// Caller is responsible for calling delete on f.contents and f.Sha256
//
// TODO: Should this use the db allocator?
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
} }
f.contents = string(data) f.contents = string(data)
f.Sha256 = current_sha digest := hash.hash_bytes(hash.Algorithm.SHA256, data, context.temp_allocator)
if !db_persist(d, f, old_path) { hex_bytes, alloc_err := hex.encode(digest)
return result, .DbFailed if alloc_err != nil {
} fmt.printf("Error generating hash for file %s: %v\n", f.Path, alloc_err)
return result + {.BackedUp}, .None return false
}
db_persist :: proc(d: ^Db, f: ^EnvFile, old_path: string) -> bool {
if f.Path != old_path {
if !db_delete(d, old_path) {
return false
}
}
return db_insert(d, f^)
}
try_move_dir :: proc(d: ^Db, f: ^EnvFile, allocator: mem.Allocator) -> (bool, SyncError) {
// TODO: Should thread the allocator through, right?
roots, ok := find_git_roots(d.cfg, allocator)
if !ok {
return false, .GitRootFailed
}
match_count := 0
matched_dir: string
for root in roots {
remotes := get_git_remotes(root, context.temp_allocator)
if shares_remote(f, remotes[:]) {
match_count += 1
matched_dir = root
}
}
switch match_count {
case 0:
return false, .DirMissing
case 1:
f.Dir = matched_dir
base := filepath.base(f.Path)
new_path, _ := filepath.join({f.Dir, base}, allocator)
f.Path = new_path
f.Remotes = get_git_remotes(f.Dir, allocator)
return true, .None
case:
return false, .MultipleDirs
} }
f.Sha256 = string(hex_bytes)
return true
} }
shares_remote :: proc(f: ^EnvFile, remotes: []string) -> bool { shares_remote :: proc(f: ^EnvFile, remotes: []string) -> bool {
@@ -527,34 +565,39 @@ shares_remote :: proc(f: ^EnvFile, remotes: []string) -> bool {
return false return false
} }
get_git_remotes :: proc(dir: string, allocator: mem.Allocator) -> [dynamic]string { // TODO: Should this use the db allocator?
config_path, _ := filepath.join({dir, ".git", "config"}, context.temp_allocator) get_git_remotes :: proc(dir: string) -> [dynamic]string {
// TODO: Handle error remotes: [dynamic]string
m, _, ok := ini.load_map_from_path(config_path, context.temp_allocator) remote_set: map[string]bool
if !ok { defer delete(remote_set)
return nil
}
remotes := make([dynamic]string, 0, 1, allocator) config_path, _ := filepath.join({dir, ".git", "config"}, context.temp_allocator)
m, _, ok := ini.load_map_from_path(config_path, context.allocator)
if !ok {
return remotes
}
defer ini.delete_map(m)
for section_name, section in m { for section_name, section in m {
if strings.has_prefix(section_name, "remote ") { if strings.has_prefix(section_name, "remote ") {
if url, ok := section["url"]; ok { if url, ok := section["url"]; ok {
found := false remote_set[url] = true
for r in remotes {
if r == url {found = true; break}
}
if !found {
cloned, _ := strings.clone(url, allocator)
append(&remotes, cloned)
}
} }
} }
} }
for remote in remote_set {
cloned, _ := strings.clone(remote)
append(&remotes, cloned)
}
return remotes return remotes
} }
db_update_required :: proc(status: SyncFlag) -> bool {
return .BackedUp in status || .DirUpdated in status
}
to_cstring :: proc { to_cstring :: proc {
string_to_cstring, string_to_cstring,
strings.to_cstring, strings.to_cstring,
@@ -569,7 +612,7 @@ string_to_cstring :: proc(s: string, allocator := context.allocator) -> cstring
return cs return cs
} }
// Unless an explicit allocator is passed, caller is responsible for freeing the result // Caller is responsible for freeing the result
clone_cstring :: proc(c: cstring, allocator := context.allocator) -> string { clone_cstring :: proc(c: cstring, allocator := context.allocator) -> string {
str, err := strings.clone_from_cstring(c, allocator) str, err := strings.clone_from_cstring(c, allocator)
if err != nil { if err != nil {

View File

@@ -1,7 +1,5 @@
package main package main
import "core:crypto/hash"
import "core:encoding/hex"
import "core:fmt" import "core:fmt"
import "core:os" import "core:os"
import "core:path/filepath" import "core:path/filepath"
@@ -16,7 +14,7 @@ make_test_env_file :: proc(path, sha, contents: string, remotes: []string = {})
Dir = "", Dir = "",
Sha256 = sha, Sha256 = sha,
contents = contents, contents = contents,
Remotes = make([dynamic]string, 0, len(remotes), context.temp_allocator), Remotes = make([dynamic]string, 0, len(remotes)),
} }
for r in remotes { for r in remotes {
append(&f.Remotes, r) append(&f.Remotes, r)
@@ -84,8 +82,6 @@ test_db_insert_or_replace :: proc(t: ^testing.T) {
fetched, fetch_ok := db_fetch(&d, "/project/.env") fetched, fetch_ok := db_fetch(&d, "/project/.env")
testing.expect(t, fetch_ok, "fetch should succeed") testing.expect(t, fetch_ok, "fetch should succeed")
if !fetch_ok do return
// defer delete_envfile(&fetched)
testing.expect_value(t, fetched.contents, "KEY=new") testing.expect_value(t, fetched.contents, "KEY=new")
testing.expect_value(t, fetched.Sha256, "sha2") testing.expect_value(t, fetched.Sha256, "sha2")
@@ -203,6 +199,37 @@ test_db_serialize :: proc(t: ^testing.T) {
testing.expect(t, sz > 0, "serialized size should be > 0") testing.expect(t, sz > 0, "serialized size should be > 0")
} }
@(test)
test_db_update_required_noop :: proc(t: ^testing.T) {
testing.expect(t, !db_update_required({}), "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) {
combined := SyncFlag{.DirUpdated, .Restored}
testing.expect(t, db_update_required(combined), "DirUpdated|Restored should require update")
}
@(test) @(test)
test_shares_remote_overlap :: proc(t: ^testing.T) { test_shares_remote_overlap :: proc(t: ^testing.T) {
f := EnvFile { f := EnvFile {
@@ -278,7 +305,8 @@ test_get_git_remotes_single :: proc(t: ^testing.T) {
err := os.write_entire_file(config_path, transmute([]u8)config_content) err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect(t, err == nil, "should write .git/config") testing.expect(t, err == nil, "should write .git/config")
remotes := get_git_remotes(base, context.temp_allocator) remotes := get_git_remotes(base)
defer delete_remotes(remotes)
testing.expect(t, len(remotes) == 1, "should find 1 remote") testing.expect(t, len(remotes) == 1, "should find 1 remote")
if len(remotes) != 1 do return if len(remotes) != 1 do return
@@ -299,7 +327,8 @@ test_get_git_remotes_multiple :: proc(t: ^testing.T) {
err := os.write_entire_file(config_path, transmute([]u8)config_content) err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect(t, err == nil, "should write .git/config") testing.expect(t, err == nil, "should write .git/config")
remotes := get_git_remotes(base, context.temp_allocator) remotes := get_git_remotes(base)
defer delete_remotes(remotes)
testing.expect(t, len(remotes) == 2, "should find 2 remotes") testing.expect(t, len(remotes) == 2, "should find 2 remotes")
} }
@@ -310,7 +339,8 @@ test_get_git_remotes_no_config :: proc(t: ^testing.T) {
os.mkdir_all(base) os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
remotes := get_git_remotes(base, context.temp_allocator) remotes := get_git_remotes(base)
defer delete_remotes(remotes)
testing.expect(t, len(remotes) == 0, "should return empty when no .git/config") testing.expect(t, len(remotes) == 0, "should return empty when no .git/config")
} }
@@ -329,7 +359,8 @@ test_get_git_remotes_no_remotes :: proc(t: ^testing.T) {
err := os.write_entire_file(config_path, transmute([]u8)config_content) err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect(t, err == nil, "should write .git/config") testing.expect(t, err == nil, "should write .git/config")
remotes := get_git_remotes(base, context.temp_allocator) remotes := get_git_remotes(base)
defer delete_remotes(remotes)
testing.expect(t, len(remotes) == 0, "should return empty when no remote sections") testing.expect(t, len(remotes) == 0, "should return empty when no remote sections")
} }
@@ -363,6 +394,49 @@ test_new_env_file_missing :: proc(t: ^testing.T) {
testing.expect(t, !ok, "missing file should return false") testing.expect(t, !ok, "missing file should return false")
} }
@(test)
test_env_file_backup :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-backup-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base)
err := os.write_entire_file(env_path, "KEY=12345\n")
testing.expect(t, err == nil, ".env file should exist")
f := EnvFile {
Path = env_path,
}
defer delete(f.contents)
defer delete(f.Sha256)
testing.expect(t, env_file_backup(&f), "backup should succeed")
testing.expect_value(t, f.contents, "KEY=12345\n")
testing.expect_value(t, len(f.Sha256), 64)
}
@(test)
test_env_file_backup_missing :: proc(t: ^testing.T) {
f := EnvFile {
Path = "/tmp/envr-nonexistent-backup/.env",
}
testing.expect(t, !env_file_backup(&f), "missing file should return false")
}
@(test)
test_update_dir :: proc(t: ^testing.T) {
f := EnvFile {
Path = "/old/project/.env",
Dir = "/old/project",
Remotes = make([dynamic]string, 0),
}
defer delete(f.Remotes)
update_dir(&f, "/new/location")
testing.expect_value(t, f.Dir, "/new/location")
testing.expect_value(t, f.Path, "/new/location/.env")
}
@(test) @(test)
test_closing_db_has_no_leaks :: proc(t: ^testing.T) { test_closing_db_has_no_leaks :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-leak-%d", os.get_pid()) base := fmt.tprintf("/tmp/envr-test-leak-%d", os.get_pid())
@@ -419,150 +493,3 @@ test_open_existing_db_has_no_leaks :: proc(t: ^testing.T) {
db_close(&db2) db_close(&db2)
} }
@(test)
test_db_sync_noop :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-sync-noop-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base)
content := "KEY=value\n"
write_err := os.write_entire_file(env_path, transmute([]u8)content)
testing.expect(t, write_err == nil, "should write .env file")
digest := hash.hash_bytes(
hash.Algorithm.SHA256,
transmute([]u8)content,
context.temp_allocator,
)
hex_bytes, _ := hex.encode(digest, context.temp_allocator)
sha := string(hex_bytes)
d, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&d)
f := make_test_env_file(env_path, sha, content)
f.Dir = base
db_insert(&d, f)
result, sync_err := db_sync(&d, &f)
testing.expect(t, sync_err == .None, "sync should not error")
testing.expect(t, result == {}, "should be noop")
}
@(test)
test_db_sync_backed_up :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-sync-backup-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base)
changed_content := "KEY=changed\n"
write_err := os.write_entire_file(env_path, transmute([]u8)changed_content)
testing.expect(t, write_err == nil, "should write .env file")
d, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&d)
f := make_test_env_file(env_path, "old_sha", "KEY=original")
f.Dir = base
db_insert(&d, f)
result, sync_err := db_sync(&d, &f)
testing.expect(t, sync_err == .None, "sync should not error")
testing.expect(t, .BackedUp in result, "should be backed up")
}
@(test)
test_db_sync_restored :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-sync-restore-%d", os.get_pid())
os.mkdir_all(base)
defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base)
d, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&d)
f := make_test_env_file(env_path, "some_sha", "SECRET=value")
f.Dir = base
defer delete(f.Remotes)
db_insert(&d, f)
result, err := db_sync(&d, &f)
testing.expect(t, err == .None, "sync should not error")
testing.expect(t, .Restored in result, "should be restored")
data, read_err := os.read_entire_file_from_path(env_path, context.temp_allocator)
testing.expect(t, read_err == nil, "file should exist after restore")
if read_err == nil {
testing.expect_value(t, string(data), "SECRET=value")
}
}
@(test)
test_db_sync_dir_missing :: proc(t: ^testing.T) {
d, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&d)
f := make_test_env_file("/nonexistent/path/.env", "sha", "KEY=val")
db_insert(&d, f)
result, err := db_sync(&d, &f)
testing.expect(t, err == .DirMissing, "should return DirMissing error")
}
@(test)
test_db_sync_moved :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-sync-moved-%d", os.get_pid())
search_root := fmt.tprintf("%s/search", base)
repo_dir := fmt.tprintf("%s/myproject", search_root)
git_dir := fmt.tprintf("%s/.git", repo_dir)
defer os.remove_all(base)
os.mkdir_all(git_dir)
config_content := "[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n"
config_path := fmt.tprintf("%s/config", git_dir)
write_err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect(t, write_err == nil, "should write .git/config")
d, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&d)
d.cfg.ScanConfig.Include = make([dynamic]string, 0, 1, context.temp_allocator)
append(&d.cfg.ScanConfig.Include, search_root)
f := make_test_env_file(
"/old/nonexistent/path/.env",
"some_sha",
"SECRET=value",
[]string{"git@github.com:user/repo.git"},
)
testing.expect(t, db_insert(&d, f), "insert should succeed")
result, err := db_sync(&d, &f)
testing.expect(t, err == .None, "sync should not error")
if err != .None do return
testing.expect(t, .DirUpdated in result, "should have DirUpdated flag")
testing.expect(t, .Restored in result, "should have Restored flag")
expected_path := fmt.tprintf("%s/.env", repo_dir)
testing.expect_value(t, f.Path, expected_path)
testing.expect_value(t, f.Dir, repo_dir)
_, old_exists := db_fetch(&d, "/old/nonexistent/path/.env")
testing.expect(t, !old_exists, "old path should be deleted from db")
new_fetched, new_ok := db_fetch(&d, expected_path)
testing.expect(t, new_ok, "new path should exist in db")
if new_ok {
testing.expect_value(t, new_fetched.contents, "SECRET=value")
}
}

View File

@@ -6,7 +6,7 @@ import "core:io"
import "core:strings" import "core:strings"
render_table :: proc(w: io.Writer, headers: []string, rows: [][]string) { render_table :: proc(w: io.Writer, headers: []string, rows: [][]string) {
col_widths := make([dynamic]int, 0, len(headers), context.temp_allocator) col_widths := make([dynamic]int, 0, len(headers))
for i in 0 ..< len(headers) { for i in 0 ..< len(headers) {
append(&col_widths, strings.rune_count(headers[i])) append(&col_widths, strings.rune_count(headers[i]))
} }
@@ -20,14 +20,11 @@ render_table :: proc(w: io.Writer, headers: []string, rows: [][]string) {
} }
b: strings.Builder b: strings.Builder
strings.builder_init(&b, context.temp_allocator) strings.builder_init(&b)
defer strings.builder_destroy(&b)
defer delete(col_widths)
hline :: proc( hline :: proc(w: io.Writer, b: ^strings.Builder, left, mid, right: string, widths: [dynamic]int) {
w: io.Writer,
b: ^strings.Builder,
left, mid, right: string,
widths: [dynamic]int,
) {
strings.write_string(b, left) strings.write_string(b, left)
for i in 0 ..< len(widths) { for i in 0 ..< len(widths) {
for _ in 0 ..< widths[i] + 2 { for _ in 0 ..< widths[i] + 2 {