refactor: Fixed a number of memory leaks.

This commit is contained in:
2026-06-12 11:05:34 -04:00
parent 22a517340a
commit 1068458f32
9 changed files with 317 additions and 295 deletions

View File

@@ -1,6 +1,9 @@
package main package main
import "core:bufio"
import "core:fmt" import "core:fmt"
import "core:io"
import "core:mem"
import "core:os" import "core:os"
import "core:strings" import "core:strings"
@@ -19,10 +22,14 @@ CommandInfo :: struct {
aliases: []string, aliases: []string,
} }
COMMANDS := []CommandInfo{ COMMANDS := []CommandInfo {
{"init", "envr init", "Set up envr", {
"init",
"envr init",
"Set up envr",
"The init command generates your initial config and saves it to\n~/.envr/config in JSON format.\n\nDuring setup, you will be prompted to select one or more ssh keys with which to\nencrypt your databse. **Make 100% sure** that you have **a remote copy** of this\nkey somewhere, otherwise your data could be lost forever.", "The init command generates your initial config and saves it to\n~/.envr/config in JSON format.\n\nDuring setup, you will be prompted to select one or more ssh keys with which to\nencrypt your databse. **Make 100% sure** that you have **a remote copy** of this\nkey somewhere, otherwise your data could be lost forever.",
{}}, {},
},
{"scan", "envr scan", "Find and select .env files for backup", "", {}}, {"scan", "envr scan", "Find and select .env files for backup", "", {}},
{"sync", "envr sync", "Update or restore your env backups", "", {}}, {"sync", "envr sync", "Update or restore your env backups", "", {}},
{"backup", "envr backup <path>", "Import a .env file into envr", "", {"add"}}, {"backup", "envr backup <path>", "Import a .env file into envr", "", {"add"}},
@@ -30,9 +37,13 @@ COMMANDS := []CommandInfo{
{"list", "envr list", "View your tracked files", "", {}}, {"list", "envr list", "View your tracked files", "", {}},
{"remove", "envr remove <path>", "Remove a .env file from your database", "", {}}, {"remove", "envr remove <path>", "Remove a .env file from your database", "", {}},
{"check", "envr check [path]", "Check if files are backed up", "", {}}, {"check", "envr check [path]", "Check if files are backed up", "", {}},
{"deps", "envr deps", "Check for missing binaries", {
"deps",
"envr deps",
"Check for missing binaries",
"envr relies on external binaries for certain functionality.\n\nThe check command reports on which binaries are available and which are not.", "envr relies on external binaries for certain functionality.\n\nThe check command reports on which binaries are available and which are not.",
{}}, {},
},
{"version", "envr version", "Show envr's version", "", {}}, {"version", "envr version", "Show envr's version", "", {}},
{"edit-config", "envr edit-config", "Edit your config with your default editor", "", {}}, {"edit-config", "envr edit-config", "Edit your config with your default editor", "", {}},
} }
@@ -60,8 +71,8 @@ parse_args :: proc() -> (cmd: Command, ok: bool) {
arg := args[i] arg := args[i]
if strings.starts_with(arg, "--") { if strings.starts_with(arg, "--") {
key := arg[2:] key := arg[2:]
if i+1 < len(args) && !strings.starts_with(args[i+1], "-") { if i + 1 < len(args) && !strings.starts_with(args[i + 1], "-") {
cmd.flags[key] = args[i+1] cmd.flags[key] = args[i + 1]
i += 2 i += 2
} else { } else {
cmd.bool_set[key] = true cmd.bool_set[key] = true
@@ -69,8 +80,8 @@ parse_args :: proc() -> (cmd: Command, ok: bool) {
} }
} else if strings.starts_with(arg, "-") && len(arg) == 2 { } else if strings.starts_with(arg, "-") && len(arg) == 2 {
key_slice := arg[1:2] key_slice := arg[1:2]
if i+1 < len(args) && !strings.starts_with(args[i+1], "-") { if i + 1 < len(args) && !strings.starts_with(args[i + 1], "-") {
cmd.flags[key_slice] = args[i+1] cmd.flags[key_slice] = args[i + 1]
i += 2 i += 2
} else { } else {
cmd.bool_set[key_slice] = true cmd.bool_set[key_slice] = true
@@ -113,45 +124,43 @@ find_command :: proc(name: string) -> (CommandInfo, bool) {
return CommandInfo{}, false return CommandInfo{}, false
} }
command_help_text :: proc(name: string) -> (string, bool) { write_command_help :: proc(name: string, w: io.Writer) -> bool {
info, found := find_command(name) info, found := find_command(name)
if !found { if !found {
return "", false return false
} }
b: strings.Builder fmt.wprintf(w, "Usage: %s [flags]\n\n", info.usage, flush = false)
strings.builder_init(&b) fmt.wprintf(w, "%s\n", info.short, flush = false)
fmt.sbprintf(&b, "Usage: %s [flags]\n\n", info.usage)
fmt.sbprintf(&b, "%s\n", info.short)
if len(info.aliases) > 0 { if len(info.aliases) > 0 {
fmt.sbprintf(&b, "\nAliases:\n %s", info.name) fmt.wprintf(w, "\nAliases:\n %s", info.name, flush = false)
for a in info.aliases { for a in info.aliases {
fmt.sbprintf(&b, ", %s", a) fmt.wprintf(w, ", %s", a, flush = false)
} }
fmt.sbprintf(&b, "\n") fmt.wprintf(w, "\n", flush = false)
} }
if len(info.long) > 0 { if len(info.long) > 0 {
fmt.sbprintf(&b, "\n%s\n", info.long) fmt.wprintf(w, "\n%s\n", info.long, flush = false)
} }
fmt.sbprintf(&b, "\nFlags:\n -h, --help help for %s\n", info.name) fmt.wprintf(w, "\nFlags:\n -h, --help help for %s\n", info.name, flush = false)
return true
s := strings.clone(strings.to_string(b))
strings.builder_destroy(&b)
return s, true
} }
print_command_help :: proc(name: string) { print_command_help :: proc(name: string) {
text, ok := command_help_text(name) bw: bufio.Writer
bufio.writer_init(&bw, io.to_writer(os.to_writer(os.stdout)), mem.DEFAULT_PAGE_SIZE)
defer bufio.writer_destroy(&bw)
w := bufio.writer_to_writer(&bw)
ok := write_command_help(name, w)
if !ok { if !ok {
fmt.printf("Unknown command: %s\n", name) fmt.printf("Unknown command: %s\n", name)
print_usage() print_usage()
return
} }
fmt.println(text) bufio.writer_flush(&bw)
} }
usage_text :: proc() -> string { usage_text :: proc() -> string {
@@ -159,7 +168,10 @@ usage_text :: proc() -> string {
strings.builder_init(&b) strings.builder_init(&b)
fmt.sbprintf(&b, "envr keeps your .env synced to a local, age encrypted database.\n") fmt.sbprintf(&b, "envr keeps your .env synced to a local, age encrypted database.\n")
fmt.sbprintf(&b, "Is a safe and easy way to gather all your .env files in one place where they can\n") fmt.sbprintf(
&b,
"Is a safe and easy way to gather all your .env files in one place where they can\n",
)
fmt.sbprintf(&b, "easily be backed by another tool such as restic or git.\n") fmt.sbprintf(&b, "easily be backed by another tool such as restic or git.\n")
fmt.sbprintf(&b, "\n") fmt.sbprintf(&b, "\n")
fmt.sbprintf(&b, "All your data is stored in ~/data.age\n") fmt.sbprintf(&b, "All your data is stored in ~/data.age\n")
@@ -184,7 +196,10 @@ usage_text :: proc() -> string {
fmt.sbprintf(&b, "\n") fmt.sbprintf(&b, "\n")
fmt.sbprintf(&b, "> envr sync\n") fmt.sbprintf(&b, "> envr sync\n")
fmt.sbprintf(&b, "\n") fmt.sbprintf(&b, "\n")
fmt.sbprintf(&b, "5. If you lose a repository, after re-cloning the repo into the same path it was\n") fmt.sbprintf(
&b,
"5. If you lose a repository, after re-cloning the repo into the same path it was\n",
)
fmt.sbprintf(&b, "at before, restore your backup with:\n") fmt.sbprintf(&b, "at before, restore your backup with:\n")
fmt.sbprintf(&b, "\n") fmt.sbprintf(&b, "\n")
fmt.sbprintf(&b, "> envr restore ~/<path to repository>/.env\n") fmt.sbprintf(&b, "> envr restore ~/<path to repository>/.env\n")
@@ -203,7 +218,7 @@ usage_text :: proc() -> string {
name_len := len(b.buf) - name_start name_len := len(b.buf) - name_start
padding := 20 - name_len padding := 20 - name_len
if padding > 0 { if padding > 0 {
for _ in 0..<padding { for _ in 0 ..< padding {
strings.write_byte(&b, ' ') strings.write_byte(&b, ' ')
} }
} }
@@ -224,3 +239,4 @@ usage_text :: proc() -> string {
print_usage :: proc() { print_usage :: proc() {
fmt.print(usage_text()) fmt.print(usage_text())
} }

View File

@@ -34,11 +34,7 @@ test_usage_text_contains_steps :: proc(t: ^testing.T) {
testing.expect(t, strings.contains(text, "4."), "missing step 4") testing.expect(t, strings.contains(text, "4."), "missing step 4")
testing.expect(t, strings.contains(text, "5."), "missing step 5") testing.expect(t, strings.contains(text, "5."), "missing step 5")
testing.expect(t, strings.contains(text, "> envr sync\n"), "step 4 missing 'envr sync'") testing.expect(t, strings.contains(text, "> envr sync\n"), "step 4 missing 'envr sync'")
testing.expect( testing.expect(t, strings.contains(text, "> envr restore"), "step 5 missing 'envr restore'")
t,
strings.contains(text, "> envr restore"),
"step 5 missing 'envr restore'",
)
} }
@(test) @(test)
@@ -47,42 +43,37 @@ test_usage_text_contains_flags_and_help_hint :: proc(t: ^testing.T) {
testing.expect(t, strings.contains(text, "Flags:"), "missing Flags section") testing.expect(t, strings.contains(text, "Flags:"), "missing Flags section")
testing.expect(t, strings.contains(text, "--help"), "missing --help flag") testing.expect(t, strings.contains(text, "--help"), "missing --help flag")
testing.expect( testing.expect(t, strings.contains(text, "Use \"envr [command] --help\""), "missing help hint")
t,
strings.contains(text, "Use \"envr [command] --help\""),
"missing help hint",
)
} }
@(test) @(test)
test_command_help_backup :: proc(t: ^testing.T) { test_command_help_backup :: proc(t: ^testing.T) {
text, ok := command_help_text("backup") b: strings.Builder
testing.expect(t, ok, "command_help_text(\"backup\") returned false") strings.builder_init(&b)
defer strings.builder_destroy(&b)
ok := write_command_help("backup", strings.to_writer(&b))
testing.expect(t, ok, "write_command_help(\"backup\") returned false")
text := strings.to_string(b)
testing.expect(t, strings.contains(text, "Usage:"), "missing Usage line") testing.expect(t, strings.contains(text, "Usage:"), "missing Usage line")
testing.expect( testing.expect(t, strings.contains(text, "envr backup <path>"), "missing usage pattern")
t, testing.expect(t, strings.contains(text, "Aliases:"), "missing Aliases section")
strings.contains(text, "envr backup <path>"),
"missing usage pattern",
)
testing.expect(
t,
strings.contains(text, "Aliases:"),
"missing Aliases section",
)
testing.expect(t, strings.contains(text, "add"), "missing 'add' alias") testing.expect(t, strings.contains(text, "add"), "missing 'add' alias")
testing.expect(t, strings.contains(text, "Flags:"), "missing Flags section") testing.expect(t, strings.contains(text, "Flags:"), "missing Flags section")
testing.expect( testing.expect(t, strings.contains(text, "--help"), "missing --help in flags")
t,
strings.contains(text, "--help"),
"missing --help in flags",
)
} }
@(test) @(test)
test_command_help_add_alias :: proc(t: ^testing.T) { test_command_help_add_alias :: proc(t: ^testing.T) {
text, ok := command_help_text("add") b: strings.Builder
testing.expect(t, ok, "command_help_text(\"add\") returned false") strings.builder_init(&b)
defer strings.builder_destroy(&b)
ok := write_command_help("add", strings.to_writer(&b))
testing.expect(t, ok, "write_command_help(\"add\") returned false")
text := strings.to_string(b)
testing.expect( testing.expect(
t, t,
strings.contains(text, "envr backup <path>"), strings.contains(text, "envr backup <path>"),
@@ -93,34 +84,43 @@ test_command_help_add_alias :: proc(t: ^testing.T) {
@(test) @(test)
test_command_help_init_no_aliases :: proc(t: ^testing.T) { test_command_help_init_no_aliases :: proc(t: ^testing.T) {
text, ok := command_help_text("init") b: strings.Builder
testing.expect(t, ok, "command_help_text(\"init\") returned false") strings.builder_init(&b)
defer strings.builder_destroy(&b)
ok := write_command_help("init", strings.to_writer(&b))
testing.expect(t, ok, "write_command_help(\"init\") returned false")
text := strings.to_string(b)
testing.expect(t, strings.contains(text, "Usage:"), "missing Usage line") testing.expect(t, strings.contains(text, "Usage:"), "missing Usage line")
testing.expect( testing.expect(t, !strings.contains(text, "Aliases:"), "init should not have Aliases section")
t,
!strings.contains(text, "Aliases:"),
"init should not have Aliases section",
)
testing.expect(t, strings.contains(text, "Flags:"), "missing Flags section") testing.expect(t, strings.contains(text, "Flags:"), "missing Flags section")
testing.expect( testing.expect(t, strings.contains(text, "help for init"), "missing 'help for init'")
t,
strings.contains(text, "help for init"),
"missing 'help for init'",
)
} }
@(test) @(test)
test_command_help_unknown :: proc(t: ^testing.T) { test_command_help_unknown :: proc(t: ^testing.T) {
text, ok := command_help_text("nonexistent") b: strings.Builder
testing.expect(t, !ok, "command_help_text(\"nonexistent\") should return false") strings.builder_init(&b)
defer strings.builder_destroy(&b)
ok := write_command_help("nonexistent", strings.to_writer(&b))
testing.expect(t, !ok, "write_command_help(\"nonexistent\") should return false")
text := strings.to_string(b)
testing.expect(t, len(text) == 0, "text should be empty for unknown command") testing.expect(t, len(text) == 0, "text should be empty for unknown command")
} }
@(test) @(test)
test_command_help_version :: proc(t: ^testing.T) { test_command_help_version :: proc(t: ^testing.T) {
text, ok := command_help_text("version") b: strings.Builder
testing.expect(t, ok, "command_help_text(\"version\") returned false") strings.builder_init(&b)
defer strings.builder_destroy(&b)
ok := write_command_help("version", strings.to_writer(&b))
testing.expect(t, ok, "write_command_help(\"version\") returned false")
text := strings.to_string(b)
testing.expect(t, strings.contains(text, "Usage:"), "missing Usage line") testing.expect(t, strings.contains(text, "Usage:"), "missing Usage line")
testing.expect( testing.expect(
t, t,
@@ -128,3 +128,4 @@ test_command_help_version :: proc(t: ^testing.T) {
"version should not have Aliases section", "version should not have Aliases section",
) )
} }

View File

@@ -6,74 +6,79 @@ import "core:path/filepath"
import "core:strings" import "core:strings"
cmd_check :: proc(cmd: ^Command) { cmd_check :: proc(cmd: ^Command) {
check_path: string feats := check_features()
if len(cmd.args) > 0 {
check_path = cmd.args[0]
} else {
cwd, cwd_err := os.get_working_directory(context.allocator)
if cwd_err != nil {
fmt.printf("Error getting current directory: %v\n", cwd_err)
return
}
check_path = cwd
}
abs_path: string check_path: string
if filepath.is_abs(check_path) { if len(cmd.args) > 0 {
abs_path = check_path check_path = cmd.args[0]
} else { } else {
resolved, abs_err := filepath.abs(check_path) cwd, cwd_err := os.get_working_directory(context.allocator)
if abs_err != nil { if cwd_err != nil {
fmt.printf("Error getting absolute path: %v\n", abs_err) fmt.printf("Error getting current directory: %v\n", cwd_err)
return return
} }
abs_path = resolved check_path = cwd
} }
db, db_ok := db_open() abs_path: string
if !db_ok { if filepath.is_abs(check_path) {
return abs_path = check_path
} } else {
defer db_close(&db) resolved, abs_err := filepath.abs(check_path)
if abs_err != nil {
fmt.printf("Error getting absolute path: %v\n", abs_err)
return
}
abs_path = resolved
}
is_dir := os.is_directory(abs_path) db, db_ok := db_open()
if !db_ok {
return
}
defer db_close(&db)
files_in_path: [dynamic]string is_dir := os.is_directory(abs_path)
if is_dir { files_in_path: [dynamic]string
if !can_scan() {
fmt.println("Error: please install fd to use the check command (https://github.com/sharkdp/fd)")
return
}
scanned, scan_ok := scan_path(abs_path, db.cfg) if is_dir {
if !scan_ok { if cant_scan(feats) {
fmt.println("Error scanning directory for .env files") fmt.println(
return "Error: please install fd to use the check command (https://github.com/sharkdp/fd)",
} )
files_in_path = scanned return
} else { }
append(&files_in_path, abs_path)
}
db_files, list_ok := db_list(&db) scanned, scan_ok := scan_path(abs_path, db.cfg)
if !list_ok { if !scan_ok {
return fmt.println("Error scanning directory for .env files")
} return
}
files_in_path = scanned
} else {
append(&files_in_path, abs_path)
}
not_backed := find_unbacked(files_in_path[:], db_files[:]) db_files, list_ok := db_list(&db)
if !list_ok {
return
}
if len(not_backed) == 0 { not_backed := find_unbacked(files_in_path[:], db_files[:])
if len(files_in_path) == 0 {
fmt.println("No .env files found in the specified directory.") if len(not_backed) == 0 {
} else { if len(files_in_path) == 0 {
fmt.println("✓ All .env files in the directory are backed up.") fmt.println("No .env files found in the specified directory.")
} } else {
} else { fmt.println("✓ All .env files in the directory are backed up.")
fmt.printf("Found %d .env file(s) that are not backed up:\n", len(not_backed)) }
for file in not_backed { } else {
fmt.printf(" %s\n", file) fmt.printf("Found %d .env file(s) that are not backed up:\n", len(not_backed))
} for file in not_backed {
fmt.println("\nRun 'envr sync' to back up these files.") fmt.printf(" %s\n", file)
} }
fmt.println("\nRun 'envr sync' to back up these files.")
}
} }

View File

@@ -5,39 +5,44 @@ import "core:testing"
@(test) @(test)
test_find_unbacked_finds_missing :: proc(t: ^testing.T) { test_find_unbacked_finds_missing :: proc(t: ^testing.T) {
local := []string{"/a/.env", "/b/.env", "/c/.env"} local := []string{"/a/.env", "/b/.env", "/c/.env"}
db := []EnvFile{{Path = "/a/.env"}, {Path = "/b/.env"}} db := []EnvFile{{Path = "/a/.env"}, {Path = "/b/.env"}}
result := find_unbacked(local, db[:]) result := find_unbacked(local, db[:])
testing.expect(t, len(result) == 1, fmt.aprintf("expected 1 unbacked, got %d", len(result))) testing.expect(t, len(result) == 1, fmt.aprintf("expected 1 unbacked, got %d", len(result)))
if len(result) > 0 { if len(result) > 0 {
testing.expect(t, result[0] == "/c/.env", fmt.aprintf("expected /c/.env, got %s", result[0])) testing.expect(
} t,
result[0] == "/c/.env",
fmt.aprintf("expected /c/.env, got %s", result[0]),
)
}
} }
@(test) @(test)
test_find_unbacked_all_backed :: proc(t: ^testing.T) { test_find_unbacked_all_backed :: proc(t: ^testing.T) {
local := []string{"/a/.env", "/b/.env"} local := []string{"/a/.env", "/b/.env"}
db := []EnvFile{{Path = "/a/.env"}, {Path = "/b/.env"}} db := []EnvFile{{Path = "/a/.env"}, {Path = "/b/.env"}}
result := find_unbacked(local, db[:]) result := find_unbacked(local, db[:])
testing.expect(t, len(result) == 0, fmt.aprintf("expected 0 unbacked, got %d", len(result))) testing.expect(t, len(result) == 0, fmt.aprintf("expected 0 unbacked, got %d", len(result)))
} }
@(test) @(test)
test_find_unbacked_no_local :: proc(t: ^testing.T) { test_find_unbacked_no_local :: proc(t: ^testing.T) {
local: []string local: []string
db := []EnvFile{{Path = "/a/.env"}} db := []EnvFile{{Path = "/a/.env"}}
result := find_unbacked(local, db[:]) result := find_unbacked(local, db[:])
testing.expect(t, len(result) == 0, fmt.aprintf("expected 0 unbacked, got %d", len(result))) testing.expect(t, len(result) == 0, fmt.aprintf("expected 0 unbacked, got %d", len(result)))
} }
@(test) @(test)
test_find_unbacked_none_backed :: proc(t: ^testing.T) { test_find_unbacked_none_backed :: proc(t: ^testing.T) {
local := []string{"/a/.env", "/b/.env"} local := []string{"/a/.env", "/b/.env"}
db: []EnvFile db: []EnvFile
result := find_unbacked(local, db[:]) result := find_unbacked(local, db[:])
testing.expect(t, len(result) == 2, fmt.aprintf("expected 2 unbacked, got %d", len(result))) testing.expect(t, len(result) == 2, fmt.aprintf("expected 2 unbacked, got %d", len(result)))
} }

View File

@@ -4,7 +4,8 @@ import "core:encoding/json"
import "core:fmt" import "core:fmt"
cmd_scan :: proc(cmd: ^Command) { cmd_scan :: proc(cmd: ^Command) {
if !can_scan() { feats := check_features()
if cant_scan(feats) {
fmt.println( fmt.println(
"Error: please install fd to use the scan command (https://github.com/sharkdp/fd)", "Error: please install fd to use the scan command (https://github.com/sharkdp/fd)",
) )

View File

@@ -6,8 +6,8 @@ import "core:testing"
@(test) @(test)
test_find_binary_exists :: proc(t: ^testing.T) { test_find_binary_exists :: proc(t: ^testing.T) {
path := os.get_env("PATH", context.allocator) path := os.get_env("PATH", context.temp_allocator)
paths := strings.split(path, ":") paths := strings.split(path, ":", context.temp_allocator)
result := find_binary(paths, "sh") result := find_binary(paths, "sh")
testing.expect(t, result != "", "sh should be found on PATH") testing.expect(t, result != "", "sh should be found on PATH")
@@ -15,7 +15,7 @@ test_find_binary_exists :: proc(t: ^testing.T) {
@(test) @(test)
test_find_binary_not_exists :: proc(t: ^testing.T) { test_find_binary_not_exists :: proc(t: ^testing.T) {
old_path := os.get_env("PATH", context.allocator) old_path := os.get_env("PATH", context.temp_allocator)
defer { defer {
if old_path != "" { if old_path != "" {
os.set_env("PATH", old_path) os.set_env("PATH", old_path)
@@ -24,8 +24,8 @@ test_find_binary_not_exists :: proc(t: ^testing.T) {
os.set_env("PATH", "/tmp/envr-nope") os.set_env("PATH", "/tmp/envr-nope")
path := os.get_env("PATH", context.allocator) path := os.get_env("PATH", context.temp_allocator)
paths := strings.split(path, ":") paths := strings.split(path, ":", context.temp_allocator)
result := find_binary(paths, "no_such_binary_xyz") result := find_binary(paths, "no_such_binary_xyz")

109
scan.odin
View File

@@ -2,23 +2,49 @@ package main
import "core:fmt" import "core:fmt"
import "core:os" import "core:os"
import "core:path/filepath"
import "core:strings" import "core:strings"
import "core:sync" import "core:sync"
fd_counter: sync.Atomic_Mutex fd_counter: sync.Atomic_Mutex
fd_seq: int fd_seq: int
next_fd_tmp_path :: proc() -> string { // Caller is responsible for freeing paths
sync.atomic_mutex_lock(&fd_counter) scan_path :: proc(search_path: string, cfg: Config) -> (paths: [dynamic]string, ok: bool) {
n := fd_seq if is_tty() {
fd_seq += 1 fmt.printf("Searching for all files in \"%s\"...\n", search_path)
sync.atomic_mutex_unlock(&fd_counter) }
return fmt.aprintf("/tmp/envr-fd-%d-%d", os.get_pid(), n) all_files, all_ok := run_fd(build_fd_args(search_path, cfg, true))
if !all_ok {
return
}
if is_tty() {
fmt.printf("Search for unignored fies in \"%s\"...\n", search_path)
}
unignored_files, unignored_ok := run_fd(build_fd_args(search_path, cfg, false))
if !unignored_ok {
return
}
unignored_set := make(map[string]bool, len(unignored_files), context.temp_allocator)
for file in unignored_files {
unignored_set[file] = true
}
for file in all_files {
if !(file in unignored_set) {
append(&paths, file)
}
}
ok = true
return
} }
@(private = "file")
build_fd_args :: proc(search_path: string, cfg: Config, include_ignored: bool) -> []string { build_fd_args :: proc(search_path: string, cfg: Config, include_ignored: bool) -> []string {
args := make([dynamic]string, 0, 3 + 2 * len(cfg.ScanConfig.Exclude) + 2) args_len := 3 + 2 * len(cfg.ScanConfig.Exclude) + 2
args := make([dynamic]string, 0, args_len, context.temp_allocator)
append(&args, "fd") append(&args, "fd")
append(&args, "-a") append(&args, "-a")
append(&args, cfg.ScanConfig.Matcher) append(&args, cfg.ScanConfig.Matcher)
@@ -38,7 +64,7 @@ build_fd_args :: proc(search_path: string, cfg: Config, include_ignored: bool) -
return args[:] return args[:]
} }
run_fd :: proc(args: []string) -> (lines: [dynamic]string, ok: bool) { run_fd :: proc(args: []string) -> (lines: []string, ok: bool) {
tmp_path := next_fd_tmp_path() tmp_path := next_fd_tmp_path()
tmp_file, tmp_err := os.open(tmp_path, os.O_CREATE | os.O_WRONLY | os.O_TRUNC) tmp_file, tmp_err := os.open(tmp_path, os.O_CREATE | os.O_WRONLY | os.O_TRUNC)
if tmp_err != nil { if tmp_err != nil {
@@ -64,7 +90,7 @@ run_fd :: proc(args: []string) -> (lines: [dynamic]string, ok: bool) {
return return
} }
data, read_err := os.read_entire_file_from_path(tmp_path, context.allocator) data, read_err := os.read_entire_file_from_path(tmp_path, context.temp_allocator)
os.remove(tmp_path) os.remove(tmp_path)
if read_err != nil { if read_err != nil {
return return
@@ -77,69 +103,44 @@ run_fd :: proc(args: []string) -> (lines: [dynamic]string, ok: bool) {
return return
} }
raw_lines := strings.split(output, "\n") raw_lines := strings.split(output, "\n", context.temp_allocator)
result := make([dynamic]string, 0, len(raw_lines), context.temp_allocator)
for line in raw_lines { for line in raw_lines {
trimmed, _ := strings.clone(strings.trim_space(line)) trimmed := strings.trim_space(line)
if len(trimmed) > 0 { if len(trimmed) > 0 {
append(&lines, trimmed) append(&result, trimmed)
} }
} }
ok = true return result[:], true
return
} }
scan_path :: proc(search_path: string, cfg: Config) -> (paths: [dynamic]string, ok: bool) { @(private = "file")
if is_tty() { next_fd_tmp_path :: proc() -> string {
fmt.printf("Searching for all files in \"%s\"...\n", search_path) sync.atomic_mutex_lock(&fd_counter)
} n := fd_seq
all_args := build_fd_args(search_path, cfg, true) fd_seq += 1
all_files, all_ok := run_fd(all_args) sync.atomic_mutex_unlock(&fd_counter)
if !all_ok { return fmt.aprintf("/tmp/envr-fd-%d-%d", os.get_pid(), n, allocator = context.temp_allocator)
return
}
if is_tty() {
fmt.printf("Search for unignored fies in \"%s\"...\n", search_path)
}
unignored_args := build_fd_args(search_path, cfg, false)
unignored_files, unignored_ok := run_fd(unignored_args)
if !unignored_ok {
return
}
unignored_set: map[string]bool
for file in unignored_files {
unignored_set[file] = true
}
for file in all_files {
if !(file in unignored_set) {
append(&paths, file)
}
}
ok = true
return
} }
can_scan :: proc() -> bool { cant_scan :: proc(feats: AvailableFeatures) -> bool {
feats := check_features() return Feature.Fd not_in feats
return Feature.Fd in feats
} }
find_unbacked :: proc(local_files: []string, db_files: []EnvFile) -> [dynamic]string { find_unbacked :: proc(local_files: []string, db_files: []EnvFile) -> []string {
backed_set: map[string]bool // Lives until the end of the function
backed_set := make(map[string]bool, len(db_files), context.temp_allocator)
for file in db_files { for file in db_files {
backed_set[file.Path] = true backed_set[file.Path] = true
} }
unbacked: [dynamic]string unbacked := make([dynamic]string, 0, len(db_files) / 2, context.temp_allocator)
for file in local_files { for file in local_files {
if !(file in backed_set) { if !(file in backed_set) {
append(&unbacked, file) append(&unbacked, file)
} }
} }
return unbacked return unbacked[:]
} }

View File

@@ -7,88 +7,81 @@ import "core:testing"
@(test) @(test)
test_scan_path_finds_gitignored_env_files :: proc(t: ^testing.T) { test_scan_path_finds_gitignored_env_files :: proc(t: ^testing.T) {
if !can_scan() { feats := check_features()
return testing.expect(t, cant_scan(feats) == false)
}
base := fmt.aprintf("/tmp/envr-scan-test-%d", os.get_pid()) base := fmt.aprintf("/tmp/envr-scan-test-%d", os.get_pid())
os.mkdir_all(base) os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
git_init := os.Process_Desc{ git_init := os.Process_Desc {
command = []string{"git", "-c", "advice.defaultBranchName=false", "init"}, command = []string{"git", "-c", "advice.defaultBranchName=false", "init"},
working_dir = base, working_dir = base,
stdout = os.stderr, stdout = os.stderr,
stderr = os.stderr, stderr = os.stderr,
} }
p, err := os.process_start(git_init) p, err := os.process_start(git_init)
if err != nil { if err != nil {
return return
} }
_, wait_err := os.process_wait(p) _, wait_err := os.process_wait(p)
if wait_err != nil { if wait_err != nil {
return return
} }
gitignore_path := fmt.aprintf("%s/.gitignore", base) gitignore_path := fmt.aprintf("%s/.gitignore", base)
_ = os.write_entire_file(gitignore_path, ".env*\n") _ = os.write_entire_file(gitignore_path, ".env*\n")
_ = os.write_entire_file(fmt.aprintf("%s/.env", base), "SECRET=1") _ = os.write_entire_file(fmt.aprintf("%s/.env", base), "SECRET=1")
_ = os.write_entire_file(fmt.aprintf("%s/.env.testing", base), "TEST=1") _ = os.write_entire_file(fmt.aprintf("%s/.env.testing", base), "TEST=1")
_ = os.write_entire_file(fmt.aprintf("%s/config.yaml", base), "key: value") _ = os.write_entire_file(fmt.aprintf("%s/config.yaml", base), "key: value")
cfg := Config{ cfg := Config {
ScanConfig = ScanConfig{ ScanConfig = ScanConfig{Matcher = "\\.env", Exclude = []string{}, Include = []string{}},
Matcher = "\\.env", }
Exclude = []string{},
Include = []string{},
},
}
results, ok := scan_path(base, cfg) results, ok := scan_path(base, cfg)
testing.expect(t, ok, "scan_path should succeed") defer delete(results)
testing.expect(t, ok, "scan_path should succeed")
found_env := false found_env := false
found_testing := false found_testing := false
found_config := false found_config := false
for path in results { for path in results {
_, filename := filepath.split(path) _, filename := filepath.split(path)
if filename == ".env" { if filename == ".env" {
found_env = true found_env = true
} }
if filename == ".env.testing" { if filename == ".env.testing" {
found_testing = true found_testing = true
} }
if filename == "config.yaml" { if filename == "config.yaml" {
found_config = true found_config = true
} }
} }
testing.expect(t, found_env, "should find .env (gitignored)") testing.expect(t, found_env, "should find .env (gitignored)")
testing.expect(t, found_testing, "should find .env.testing (gitignored)") testing.expect(t, found_testing, "should find .env.testing (gitignored)")
testing.expect(t, !found_config, "should NOT find config.yaml (not gitignored)") testing.expect(t, !found_config, "should NOT find config.yaml (not gitignored)")
} }
@(test) @(test)
test_scan_path_empty_dir :: proc(t: ^testing.T) { test_scan_path_empty_dir :: proc(t: ^testing.T) {
if !can_scan() { feats := check_features()
return testing.expect(t, cant_scan(feats) == false)
}
base := fmt.aprintf("/tmp/envr-scan-empty-%d", os.get_pid()) base := fmt.aprintf("/tmp/envr-scan-empty-%d", os.get_pid())
os.mkdir_all(base) os.mkdir_all(base)
defer os.remove_all(base) defer os.remove_all(base)
cfg := Config{ cfg := Config {
ScanConfig = ScanConfig{ ScanConfig = ScanConfig{Matcher = "\\.env", Exclude = []string{}, Include = []string{}},
Matcher = "\\.env", }
Exclude = []string{},
Include = []string{},
},
}
results, ok := scan_path(base, cfg) results, ok := scan_path(base, cfg)
testing.expect(t, ok, "scan_path should succeed") defer delete(results)
testing.expect(t, len(results) == 0, fmt.aprintf("expected 0 results, got %d", len(results))) testing.expect(t, ok, "scan_path should succeed")
testing.expect(t, len(results) == 0, fmt.aprintf("expected 0 results, got %d", len(results)))
} }

View File

@@ -15,11 +15,11 @@ render_table :: proc(headers: []string, rows: [][]string) {
} }
col_widths := make([dynamic]int, 0, len(headers)) 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]))
} }
for r in rows { for r in rows {
for i in 0..<len(r) { for i in 0 ..< len(r) {
w := strings.rune_count(r[i]) w := strings.rune_count(r[i])
if i < len(col_widths) && w > col_widths[i] { if i < len(col_widths) && w > col_widths[i] {
col_widths[i] = w col_widths[i] = w
@@ -34,11 +34,11 @@ render_table :: proc(headers: []string, rows: [][]string) {
hline :: proc(b: ^strings.Builder, left, mid, right: string, widths: [dynamic]int) { hline :: proc(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 {
strings.write_string(b, "\u2500") strings.write_string(b, "\u2500")
} }
if i < len(widths)-1 { if i < len(widths) - 1 {
strings.write_string(b, mid) strings.write_string(b, mid)
} else { } else {
strings.write_string(b, right) strings.write_string(b, right)
@@ -56,7 +56,7 @@ render_table :: proc(headers: []string, rows: [][]string) {
} }
strings.write_string(&b, "\u2502") strings.write_string(&b, "\u2502")
for i in 0..<len(headers) { for i in 0 ..< len(headers) {
cell(&b, headers[i], col_widths[i]) cell(&b, headers[i], col_widths[i])
} }
fmt.println(strings.to_string(b)) fmt.println(strings.to_string(b))
@@ -66,7 +66,7 @@ render_table :: proc(headers: []string, rows: [][]string) {
for r in rows { for r in rows {
strings.write_string(&b, "\u2502") strings.write_string(&b, "\u2502")
for i in 0..<len(r) { for i in 0 ..< len(r) {
cell(&b, r[i], col_widths[i]) cell(&b, r[i], col_widths[i])
} }
fmt.println(strings.to_string(b)) fmt.println(strings.to_string(b))
@@ -77,12 +77,11 @@ render_table :: proc(headers: []string, rows: [][]string) {
} }
render_json_rows :: proc(w: io.Writer, headers: []string, rows: [][]string) { render_json_rows :: proc(w: io.Writer, headers: []string, rows: [][]string) {
entries := make([dynamic]map[string]string, 0, len(rows)) entries := make([dynamic]map[string]string, 0, len(rows), context.temp_allocator)
defer delete(entries)
for row in rows { for row in rows {
entry: map[string]string entry := make(map[string]string, len(headers), context.temp_allocator)
for i in 0..<len(headers) { for i in 0 ..< len(headers) {
entry[headers[i]] = row[i] entry[headers[i]] = row[i]
} }
append(&entries, entry) append(&entries, entry)
@@ -95,3 +94,4 @@ render_json_rows :: proc(w: io.Writer, headers: []string, rows: [][]string) {
} }
io.write_string(w, string(data)) io.write_string(w, string(data))
} }