1 Commits

Author SHA1 Message Date
07e60e4ab2 refactor: Cleaned up the sync and scan commands. 2026-06-18 18:37:24 -04:00
16 changed files with 85 additions and 257 deletions

View File

@@ -1,13 +1,11 @@
# TODOs # TODOs
1. envr scan crashes when there are zero results. 1. Consider giving db its own allocator
27. Commands are still leaking. 27. Commands are still leaking.
28. **db.odin** — Inconsistencies in how struct vs sqlite are named. 28. **db.odin** — Inconsistencies in how struct vs sqlite are named.
29. Add color flag and support non colored output.
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

@@ -75,7 +75,6 @@ parse_args :: proc(args: []string, out: io.Stream, err: io.Stream) -> (cmd: Comm
cmd.flags = make(map[string]string) cmd.flags = make(map[string]string)
cmd.bool_set = make(map[string]bool) cmd.bool_set = make(map[string]bool)
// TODO: Optimize loop?
i := 2 i := 2
for i < len(args) { for i < len(args) {
arg := args[i] arg := args[i]
@@ -137,38 +136,13 @@ write_command_help :: proc(name: string, w: io.Writer) -> bool {
return false return false
} }
fmt.wprintf( fmt.wprintf(w, "Usage: %s [flags]\n\n", info.usage, flush = false)
w, fmt.wprintf(w, "%s\n", info.short, flush = false)
"%s\n\n\n" +
COLOR_HEADINGS +
"Usage:" +
ANSI_RESET +
"\n\n " +
COLOR_FLAGS +
"%s" +
ANSI_RESET +
" [flags]\n\n",
info.short,
info.usage,
flush = false,
)
if len(info.aliases) > 0 { if len(info.aliases) > 0 {
fmt.wprintf( fmt.wprintf(w, "\nAliases:\n %s", info.name, flush = false)
w,
"\n" +
COLOR_HEADINGS +
"Aliases:" +
ANSI_RESET +
"\n\n " +
COLOR_COMMANDS +
"%s" +
ANSI_RESET,
info.name,
flush = false,
)
for a in info.aliases { for a in info.aliases {
fmt.wprintf(w, ", " + COLOR_COMMANDS + "%s" + ANSI_RESET, a, flush = false) fmt.wprintf(w, ", %s", a, flush = false)
} }
fmt.wprintf(w, "\n", flush = false) fmt.wprintf(w, "\n", flush = false)
} }
@@ -179,20 +153,7 @@ write_command_help :: proc(name: string, w: io.Writer) -> bool {
fmt.wprintf( fmt.wprintf(
w, w,
"\n" + "\nFlags:\n -h, --help help for %s\n -c, --config-file <path> config file (default \"~/.envr/config.json\")\n",
COLOR_HEADINGS +
"Flags:" +
ANSI_RESET +
"\n\n " +
COLOR_FLAGS +
"-h, --help" +
ANSI_RESET +
" help for %s\n " +
COLOR_FLAGS +
"-c, --config-file" +
ANSI_RESET +
` <path> config file (default "~/.envr/config.json")
`,
info.name, info.name,
flush = false, flush = false,
) )
@@ -217,11 +178,11 @@ find_command :: proc(name: string) -> (CommandInfo, bool) {
write_usage :: proc(w: io.Writer) { write_usage :: proc(w: io.Writer) {
fmt.wprintf( fmt.wprintf(
w, w,
`envr keeps your .env synced to a local, encrypted database. `envr keeps your .env synced to a local, age encrypted database.
Is a safe and easy way to gather all your .env files in one place where they can Is a safe and easy way to gather all your .env files in one place where they can
easily be backed by another tool such as restic or git. easily be backed by another tool such as restic or git.
All your data is stored in ~/.envr/data.envr All your data is stored in ~/data.age
Getting started is easy: Getting started is easy:
@@ -248,29 +209,21 @@ at before, restore your backup with:
> envr restore ~/<path to repository>/.env > envr restore ~/<path to repository>/.env
%sUsage:%s Usage:
envr [command]
%senvr%s [command] Available Commands:
%sAvailable Commands:%s
`, `,
COLOR_HEADINGS,
ANSI_RESET,
COLOR_FLAGS,
ANSI_RESET,
COLOR_HEADINGS,
ANSI_RESET,
flush = false, flush = false,
) )
for c in COMMANDS { for c in COMMANDS {
name_start := len(c.name) name_start := len(c.name)
fmt.wprintf(w, " %s%s", COLOR_COMMANDS, c.name, flush = false) fmt.wprintf(w, "%s", c.name, flush = false)
for a in c.aliases { for a in c.aliases {
fmt.wprintf(w, ", %s", a, flush = false) fmt.wprintf(w, ", %s", a, flush = false)
name_start += len(a) + 2 name_start += len(a) + 2
} }
fmt.wprint(w, ANSI_RESET)
padding := 20 - name_start padding := 20 - name_start
if padding > 0 { if padding > 0 {
for _ in 0 ..< padding { for _ in 0 ..< padding {
@@ -282,32 +235,24 @@ at before, restore your backup with:
fmt.wprintf( fmt.wprintf(
w, w,
"\n" + `
COLOR_HEADINGS + Flags:
"Flags:" + -h, --help help for envr
ANSI_RESET + -c, --config-file <path> config file (default "~/.envr/config.json")
"\n\n " +
COLOR_FLAGS +
"-h, --help" +
ANSI_RESET +
" help for envr\n" +
COLOR_FLAGS +
` -c, --config-file` +
ANSI_RESET +
` <path> config file (default "~/.envr/config.json")
Use "` + Use "envr [command] --help" for more information about a command.
COLOR_FLAGS +
"envr" +
ANSI_RESET +
` [command] --help" for more information about a command.
`, `,
flush = false, flush = false,
) )
} }
has_flag :: proc(cmd: ^Command, name: string) -> bool { has_flag :: proc(cmd: ^Command, name: string) -> bool {
return name in cmd.flags || name in cmd.bool_set _, ok := cmd.flags[name]
if ok {
return true
}
_, ok2 := cmd.bool_set[name]
return ok2
} }
delete_command :: proc(cmd: ^Command) { delete_command :: proc(cmd: ^Command) {

View File

@@ -57,7 +57,7 @@ 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(t, strings.contains(text, "Use \"envr [command] --help\""), "missing help hint") testing.expect(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) {

View File

@@ -4,8 +4,6 @@ import "core:fmt"
import "core:os" import "core:os"
import "core:path/filepath" import "core:path/filepath"
// TODO: What happens if you pass a non existent path to cmd_check?
// TODO: UX could be improved, so "run envr add ." if file not exists.
cmd_check :: proc(cmd: ^Command) { cmd_check :: proc(cmd: ^Command) {
check_path: string check_path: string
if len(cmd.args) > 0 { if len(cmd.args) > 0 {
@@ -39,8 +37,7 @@ cmd_check :: proc(cmd: ^Command) {
is_dir := os.is_directory(abs_path) is_dir := os.is_directory(abs_path)
// TODO: set a reasonable default files_in_path: [dynamic]string
files_in_path := make([dynamic]string, context.temp_allocator)
if is_dir { if is_dir {
scanned, scan_ok := scan_path(abs_path, db.cfg) scanned, scan_ok := scan_path(abs_path, db.cfg)

View File

@@ -23,15 +23,9 @@ cmd_scan :: proc(cmd: ^Command) {
} }
// TODO: Figure out a sane default // TODO: Figure out a sane default
// Can't use temp allocator becuase strings inside are copied to context.allocator all_files: [dynamic]string
all_files := make([dynamic]string)
defer {
for &f in all_files {delete(f)}
delete(all_files)
}
for dir in search_dirs { for dir in search_dirs {
found, scan_ok := scan_path(dir, db.cfg) found, scan_ok := scan_path(dir, db.cfg)
defer delete(found)
if !scan_ok { if !scan_ok {
fmt.wprintf(cmd.err, "Error scanning %s\n", dir, flush = false) fmt.wprintf(cmd.err, "Error scanning %s\n", dir, flush = false)
continue continue

View File

@@ -25,15 +25,7 @@ cmd_sync :: proc(cmd: ^Command) {
return return
} }
// TODO: Can't use temp allocator becuase strings inside are copied to context.allocator results := make([]SyncEntry, len(files), context.temp_allocator)
results := make([]SyncEntry, len(files))
defer {
for &e in results {
delete(e.Path)
delete(e.Status)
}
delete(results)
}
for &file, i in files { for &file, i in files {
result, err := db_sync(&db, &file) result, err := db_sync(&db, &file)
@@ -72,7 +64,7 @@ cmd_sync :: proc(cmd: ^Command) {
render_table(cmd.out, headers, table_rows[:]) render_table(cmd.out, headers, table_rows[:])
} else { } else {
data, marshal_err := json.marshal(results[:], allocator = context.temp_allocator) data, marshal_err := json.marshal(results[:])
if marshal_err != nil { if marshal_err != nil {
fmt.wprintf(cmd.err, "Error marshaling JSON: %v\n", marshal_err, flush = false) fmt.wprintf(cmd.err, "Error marshaling JSON: %v\n", marshal_err, flush = false)
return return

View File

@@ -1,15 +0,0 @@
package main
import "core:terminal/ansi"
COLOR_HEADINGS ::
ansi.CSI + ansi.FG_BRIGHT_GREEN + ";" + ansi.BOLD + ";" + ansi.UNDERLINE + ansi.SGR
COLOR_COMMANDS :: ansi.CSI + ansi.FG_BRIGHT_CYAN + ";" + ansi.BOLD + ansi.SGR
COLOR_EXAMPLE :: ansi.CSI + ansi.ITALIC + ansi.SGR
COLOR_FLAGS :: ansi.CSI + ansi.BOLD + ";" + ansi.FG_BRIGHT_WHITE + ansi.SGR
ANSI_RESET :: ansi.CSI + ansi.RESET + ansi.SGR

View File

@@ -84,9 +84,9 @@ save_config :: proc(cfg: Config, force: bool = false) -> bool {
} }
if os.exists(cfg.config_path) && !force { if os.exists(cfg.config_path) && !force {
info, stat_err := os.stat(cfg.config_path, context.temp_allocator) info, stat_err := os.stat(cfg.config_path, context.allocator)
if stat_err == nil { if stat_err == nil {
defer os.file_info_delete(info, context.temp_allocator) defer os.file_info_delete(info, context.allocator)
if info.size > 0 { if info.size > 0 {
fmt.println("Config file already exists. Run again with --force to reinitialize.") fmt.println("Config file already exists. Run again with --force to reinitialize.")
return false return false
@@ -94,15 +94,12 @@ save_config :: proc(cfg: Config, force: bool = false) -> bool {
} }
} }
data, marshal_err := json.marshal( data, marshal_err := json.marshal(cfg, {pretty = true, use_spaces = true, spaces = 2})
cfg,
{pretty = true, use_spaces = true, spaces = 2},
context.temp_allocator,
)
if marshal_err != nil { if marshal_err != nil {
fmt.printf("Error marshaling config: %v\n", marshal_err) fmt.printf("Error marshaling config: %v\n", marshal_err)
return false return false
} }
defer delete(data)
write_err := os.write_entire_file(cfg.config_path, data) write_err := os.write_entire_file(cfg.config_path, data)
if write_err != nil { if write_err != nil {

View File

@@ -33,12 +33,12 @@ init_sodium :: proc "contextless" () {
} }
} }
// TODO: Optimize performance
encrypt :: proc(plaintext: []u8, keys: []SshKeyPair) -> (ciphertext: []u8, ok: bool) { encrypt :: proc(plaintext: []u8, keys: []SshKeyPair) -> (ciphertext: []u8, ok: bool) {
x25519_pairs, pairs_ok := ssh_to_x25519(keys, context.temp_allocator) x25519_pairs, pairs_ok := ssh_to_x25519(keys)
if !pairs_ok { if !pairs_ok {
return return
} }
defer delete(x25519_pairs)
sym_key: [CRYPTO_SECRETBOX_KEY_BYTES]u8 sym_key: [CRYPTO_SECRETBOX_KEY_BYTES]u8
randombytes_buf(&sym_key[0], CRYPTO_SECRETBOX_KEY_BYTES) randombytes_buf(&sym_key[0], CRYPTO_SECRETBOX_KEY_BYTES)
@@ -47,7 +47,7 @@ encrypt :: proc(plaintext: []u8, keys: []SshKeyPair) -> (ciphertext: []u8, ok: b
randombytes_buf(&main_nonce[0], CRYPTO_SECRETBOX_NONCE_BYTES) randombytes_buf(&main_nonce[0], CRYPTO_SECRETBOX_NONCE_BYTES)
ct_len := len(plaintext) + CRYPTO_SECRETBOX_MAC_BYTES ct_len := len(plaintext) + CRYPTO_SECRETBOX_MAC_BYTES
secret_ct := make([]u8, ct_len, context.temp_allocator) secret_ct := make([]u8, ct_len)
pt_ptr: [^]u8 pt_ptr: [^]u8
if len(plaintext) > 0 { if len(plaintext) > 0 {
pt_ptr = &plaintext[0] pt_ptr = &plaintext[0]
@@ -66,7 +66,7 @@ encrypt :: proc(plaintext: []u8, keys: []SshKeyPair) -> (ciphertext: []u8, ok: b
} }
num_recipients := u32(len(x25519_pairs)) num_recipients := u32(len(x25519_pairs))
entries := make([]RecipientEntry, num_recipients, context.temp_allocator) entries := make([]RecipientEntry, num_recipients)
for i in 0 ..< len(x25519_pairs) { for i in 0 ..< len(x25519_pairs) {
for j in 0 ..< CRYPTO_BOX_PUBLICKEY_BYTES { for j in 0 ..< CRYPTO_BOX_PUBLICKEY_BYTES {
@@ -126,6 +126,8 @@ encrypt :: proc(plaintext: []u8, keys: []SshKeyPair) -> (ciphertext: []u8, ok: b
mem.copy(&ciphertext[pos], &secret_ct[0], ct_len) mem.copy(&ciphertext[pos], &secret_ct[0], ct_len)
delete(entries)
delete(secret_ct)
ok = true ok = true
return return
} }
@@ -174,10 +176,11 @@ decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: b
enc_nonce: [CRYPTO_BOX_NONCE_BYTES]u8 enc_nonce: [CRYPTO_BOX_NONCE_BYTES]u8
enc_pub: [CRYPTO_BOX_PUBLICKEY_BYTES]u8 enc_pub: [CRYPTO_BOX_PUBLICKEY_BYTES]u8
x25519_pairs, pairs_ok := ssh_to_x25519(keys, context.temp_allocator) x25519_pairs, pairs_ok := ssh_to_x25519(keys)
if !pairs_ok { if !pairs_ok {
return return
} }
defer delete(x25519_pairs)
found := false found := false
matched_pi := 0 matched_pi := 0
@@ -269,39 +272,33 @@ decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: b
return return
} }
ssh_to_x25519 :: proc( ssh_to_x25519 :: proc(keys: []SshKeyPair) -> (pairs: []X25519Keypair, ok: bool) {
keys: []SshKeyPair,
allocator := context.temp_allocator,
) -> (
[]X25519Keypair,
bool,
) {
if len(keys) == 0 { if len(keys) == 0 {
return {}, false return
} }
pairs := make([]X25519Keypair, len(keys), allocator) pairs = make([]X25519Keypair, len(keys))
for i in 0 ..< len(keys) { for i in 0 ..< len(keys) {
ssh_kp, parse_ok := parse_ssh_private_key(keys[i].Private) ssh_kp, parse_ok := parse_ssh_private_key(keys[i].Private)
if !parse_ok { if !parse_ok {
fmt.printf("Error: failed to parse SSH private key: %s\n", keys[i].Private) fmt.printf("Error: failed to parse SSH private key: %s\n", keys[i].Private)
delete(pairs) delete(pairs)
return pairs, false return
} }
ssh_pub, pub_ok := parse_ssh_public_key(keys[i].Public) ssh_pub, pub_ok := parse_ssh_public_key(keys[i].Public)
if !pub_ok { if !pub_ok {
fmt.printf("Error: failed to parse SSH public key: %s\n", keys[i].Public) fmt.printf("Error: failed to parse SSH public key: %s\n", keys[i].Public)
delete(pairs) delete(pairs)
return pairs, false return
} }
pk_rc := crypto_sign_ed25519_pk_to_curve25519(&pairs[i].Public[0], &ssh_pub[0]) pk_rc := crypto_sign_ed25519_pk_to_curve25519(&pairs[i].Public[0], &ssh_pub[0])
if pk_rc != 0 { if pk_rc != 0 {
fmt.println("Error: failed to convert ed25519 public key to curve25519") fmt.println("Error: failed to convert ed25519 public key to curve25519")
delete(pairs) delete(pairs)
return pairs, false return
} }
ed25519_sk: [64]u8 ed25519_sk: [64]u8
@@ -316,10 +313,11 @@ ssh_to_x25519 :: proc(
if sk_rc != 0 { if sk_rc != 0 {
fmt.println("Error: failed to convert ed25519 private key to curve25519") fmt.println("Error: failed to convert ed25519 private key to curve25519")
delete(pairs) delete(pairs)
return pairs, false return
} }
} }
return pairs, true ok = true
return
} }

14
db.odin
View File

@@ -407,7 +407,7 @@ new_env_file :: proc(path: string) -> (EnvFile, bool) {
digest := hash.hash_bytes(hash.Algorithm.SHA256, data, context.temp_allocator) digest := hash.hash_bytes(hash.Algorithm.SHA256, data, context.temp_allocator)
// TODO: Handle error // TODO: Handle error
hex_bytes, _ := hex.encode(digest, context.temp_allocator) hex_bytes, _ := hex.encode(digest)
return EnvFile { return EnvFile {
Path = abs_path, Path = abs_path,
@@ -485,16 +485,11 @@ db_persist :: proc(d: ^Db, f: ^EnvFile, old_path: string) -> bool {
} }
try_move_dir :: proc(d: ^Db, f: ^EnvFile, allocator: mem.Allocator) -> (bool, SyncError) { try_move_dir :: proc(d: ^Db, f: ^EnvFile, allocator: mem.Allocator) -> (bool, SyncError) {
roots, ok := find_git_roots(d.cfg) // TODO: Should thread the allocator through, right?
roots, ok := find_git_roots(d.cfg, allocator)
if !ok { if !ok {
return false, .GitRootFailed return false, .GitRootFailed
} }
defer {
for root in roots {
delete(root)
}
delete(roots)
}
match_count := 0 match_count := 0
matched_dir: string matched_dir: string
@@ -510,7 +505,7 @@ try_move_dir :: proc(d: ^Db, f: ^EnvFile, allocator: mem.Allocator) -> (bool, Sy
case 0: case 0:
return false, .DirMissing return false, .DirMissing
case 1: case 1:
f.Dir, _ = strings.clone(matched_dir, allocator) f.Dir = matched_dir
base := filepath.base(f.Path) base := filepath.base(f.Path)
new_path, _ := filepath.join({f.Dir, base}, allocator) new_path, _ := filepath.join({f.Dir, base}, allocator)
f.Path = new_path f.Path = new_path
@@ -550,7 +545,6 @@ get_git_remotes :: proc(dir: string, allocator: mem.Allocator) -> [dynamic]strin
if r == url {found = true; break} if r == url {found = true; break}
} }
if !found { if !found {
// FIXME: Currently leaks when adding a file with envr scan
cloned, _ := strings.clone(url, allocator) cloned, _ := strings.clone(url, allocator)
append(&remotes, cloned) append(&remotes, cloned)
} }

View File

@@ -309,6 +309,7 @@ test_ssh_key_parse_from_fixtures :: proc(t: ^testing.T) {
if !x_ok { if !x_ok {
return return
} }
defer delete(x25519_pairs)
testing.expect(t, len(x25519_pairs) == 1, "should have 1 x25519 keypair") testing.expect(t, len(x25519_pairs) == 1, "should have 1 x25519 keypair")
} }

View File

@@ -4,11 +4,11 @@ Manage your .env files.
### Synopsis ### Synopsis
envr keeps your .env synced to a local, encrypted database. envr keeps your .env synced to a local, age encrypted database.
Is a safe and eay way to gather all your .env files in one place where they can Is a safe and eay way to gather all your .env files in one place where they can
easily be backed by another tool such as restic or git. easily be backed by another tool such as restic or git.
All your data is stored in ~/.envr/data.envr All your data is stored in ~/data.age
Getting started is easy: Getting started is easy:

View File

@@ -36,11 +36,11 @@
inputs', inputs',
... ...
}: }:
let let
mysqlite = pkgs.sqlite.overrideAttrs (old: { mysqlite = pkgs.sqlite.overrideAttrs (old: {
configureFlags = (old.configureFlags or [ ]) ++ [ "--enable-deserialize" ]; configureFlags = (old.configureFlags or [ ]) ++ [ "--enable-deserialize" ];
}); });
in in
{ {
_module.args.pkgs = import nixpkgs { _module.args.pkgs = import nixpkgs {
inherit system; inherit system;
@@ -79,13 +79,6 @@
mysqlite mysqlite
]; ];
doCheck = true;
checkPhase = ''
runHook preCheck
odin test . -all-packages
runHook postCheck
'';
buildPhase = '' buildPhase = ''
runHook preBuild runHook preBuild
echo '${version}' > version.txt echo '${version}' > version.txt

View File

@@ -36,7 +36,7 @@ multi_select :: proc(
return return
} }
selected = make([dynamic]bool, len(options)) selected = make([dynamic]bool, 0, len(options))
cursor: int = 0 cursor: int = 0
scroll_offset: int = 0 scroll_offset: int = 0

View File

@@ -4,7 +4,6 @@ import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:io" import "core:io"
import "core:strings" import "core:strings"
import "core:terminal/ansi"
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), context.temp_allocator)
@@ -46,38 +45,14 @@ render_table :: proc(w: io.Writer, headers: []string, rows: [][]string) {
hline(w, &b, "\u250c", "\u252c", "\u2510", col_widths) hline(w, &b, "\u250c", "\u252c", "\u2510", col_widths)
cell :: proc(b: ^strings.Builder, s: string, width: int, color: string = "", center := false) { cell :: proc(b: ^strings.Builder, s: string, width: int) {
before: int extra := len(s) - strings.rune_count(s)
after: int fmt.sbprintf(b, " %-*s \u2502", width + extra, s)
total_pad := width - strings.rune_count(s)
if center {
before = total_pad / 2
after = total_pad - before
} else {
before = 0
after = total_pad
}
fmt.sbprintf(
b,
" %s%s%s%*s%s%*s%s \u2502",
ansi.CSI,
color,
ansi.SGR,
before,
"",
s,
after,
"",
ansi.CSI + ansi.RESET + ansi.SGR,
)
} }
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], ansi.FG_BRIGHT_GREEN, true) cell(&b, headers[i], col_widths[i])
} }
fmt.wprintf(w, "%s\n", strings.to_string(b), flush = false) fmt.wprintf(w, "%s\n", strings.to_string(b), flush = false)
strings.builder_reset(&b) strings.builder_reset(&b)

View File

@@ -3,7 +3,6 @@ package main
import "core:encoding/json" import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:strings" import "core:strings"
import "core:terminal/ansi"
import "core:testing" import "core:testing"
@(test) @(test)
@@ -117,30 +116,13 @@ test_render_table_normal :: proc(t: ^testing.T) {
output := strings.to_string(b) output := strings.to_string(b)
g := ansi.CSI + ansi.FG_BRIGHT_GREEN + ansi.SGR expected := `┌──────┬─────────────────────────┐
r := ANSI_RESET │ Name │ Path │
n := ansi.CSI + ansi.SGR ├──────┼─────────────────────────┤
│ foo │ /home/user/.env │
expected := fmt.tprintf( │ bar │ /home/user/project/.env │
"┌───────────────────────────────┐\n" + ───────────────────────────────
"│ %sName%s │ %s Path %s │\n" + `
"├──────┼─────────────────────────┤\n" +
"│ %sfoo %s │ %s/home/user/.env %s │\n" +
"│ %sbar %s │ %s/home/user/project/.env%s │\n" +
"└──────┴─────────────────────────┘\n",
g,
r,
g,
r,
n,
r,
n,
r,
n,
r,
n,
r,
)
testing.expect( testing.expect(
t, t,
output == expected, output == expected,
@@ -166,17 +148,11 @@ test_render_table_empty :: proc(t: ^testing.T) {
output := strings.to_string(b) output := strings.to_string(b)
g := ansi.CSI + ansi.FG_BRIGHT_GREEN + ansi.SGR expected := `┌──────┐
r := ANSI_RESET │ Name │
├──────┤
expected := fmt.tprintf( └──────┘
"┌──────┐\n" + `
"│ %sName%s │\n" +
"├──────┤\n" +
"└──────┘\n",
g,
r,
)
testing.expect( testing.expect(
t, t,
output == expected, output == expected,
@@ -202,30 +178,13 @@ test_render_table_unicode :: proc(t: ^testing.T) {
output := strings.to_string(b) output := strings.to_string(b)
g := ansi.CSI + ansi.FG_BRIGHT_GREEN + ansi.SGR expected := `┌─────────────┬────────┐
r := ANSI_RESET │ Status │ Detail │
n := ansi.CSI + ansi.SGR ├─────────────┼────────┤
│ ✓ Available │ ok │
expected := fmt.tprintf( │ ✗ Missing │ fail │
"┌─────────────────────┐\n" + ─────────────────────
"│ %s Status %s │ %sDetail%s │\n" + `
"├─────────────┼────────┤\n" +
"│ %s✓ Available%s │ %sok %s │\n" +
"│ %s✗ Missing %s │ %sfail %s │\n" +
"└─────────────┴────────┘\n",
g,
r,
g,
r,
n,
r,
n,
r,
n,
r,
n,
r,
)
testing.expect( testing.expect(
t, t,
output == expected, output == expected,