5 Commits

16 changed files with 257 additions and 85 deletions

View File

@@ -1,11 +1,13 @@
# TODOs # TODOs
1. Consider giving db its own allocator 1. envr scan crashes when there are zero results.
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,6 +75,7 @@ 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]
@@ -136,13 +137,38 @@ write_command_help :: proc(name: string, w: io.Writer) -> bool {
return false return false
} }
fmt.wprintf(w, "Usage: %s [flags]\n\n", info.usage, flush = false) fmt.wprintf(
fmt.wprintf(w, "%s\n", info.short, flush = false) w,
"%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(w, "\nAliases:\n %s", info.name, flush = false) fmt.wprintf(
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, ", %s", a, flush = false) fmt.wprintf(w, ", " + COLOR_COMMANDS + "%s" + ANSI_RESET, a, flush = false)
} }
fmt.wprintf(w, "\n", flush = false) fmt.wprintf(w, "\n", flush = false)
} }
@@ -153,7 +179,20 @@ write_command_help :: proc(name: string, w: io.Writer) -> bool {
fmt.wprintf( fmt.wprintf(
w, w,
"\nFlags:\n -h, --help help for %s\n -c, --config-file <path> config file (default \"~/.envr/config.json\")\n", "\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,
) )
@@ -178,11 +217,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, age encrypted database. `envr keeps your .env synced to a local, 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 ~/data.age All your data is stored in ~/.envr/data.envr
Getting started is easy: Getting started is easy:
@@ -209,21 +248,29 @@ at before, restore your backup with:
> envr restore ~/<path to repository>/.env > envr restore ~/<path to repository>/.env
Usage: %sUsage:%s
envr [command]
Available Commands: %senvr%s [command]
%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", c.name, flush = false) fmt.wprintf(w, " %s%s", COLOR_COMMANDS, 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 {
@@ -235,24 +282,32 @@ Available Commands:
fmt.wprintf( fmt.wprintf(
w, w,
` "\n" +
Flags: COLOR_HEADINGS +
-h, --help help for envr "Flags:" +
-c, --config-file <path> config file (default "~/.envr/config.json") ANSI_RESET +
"\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 "envr [command] --help" for more information about a command. Use "` +
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 {
_, ok := cmd.flags[name] return name in cmd.flags || name in cmd.bool_set
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, "[command] --help"), "missing help hint") testing.expect(t, strings.contains(text, "[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,6 +4,8 @@ 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 {
@@ -37,7 +39,8 @@ cmd_check :: proc(cmd: ^Command) {
is_dir := os.is_directory(abs_path) is_dir := os.is_directory(abs_path)
files_in_path: [dynamic]string // TODO: set a reasonable default
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,9 +23,15 @@ cmd_scan :: proc(cmd: ^Command) {
} }
// TODO: Figure out a sane default // TODO: Figure out a sane default
all_files: [dynamic]string // Can't use temp allocator becuase strings inside are copied to context.allocator
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,7 +25,15 @@ cmd_sync :: proc(cmd: ^Command) {
return return
} }
results := make([]SyncEntry, len(files), context.temp_allocator) // TODO: Can't use temp allocator becuase strings inside are copied to context.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)
@@ -64,7 +72,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[:]) data, marshal_err := json.marshal(results[:], allocator = context.temp_allocator)
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

15
colors.odin Normal file
View File

@@ -0,0 +1,15 @@
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.allocator) info, stat_err := os.stat(cfg.config_path, context.temp_allocator)
if stat_err == nil { if stat_err == nil {
defer os.file_info_delete(info, context.allocator) defer os.file_info_delete(info, context.temp_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,12 +94,15 @@ save_config :: proc(cfg: Config, force: bool = false) -> bool {
} }
} }
data, marshal_err := json.marshal(cfg, {pretty = true, use_spaces = true, spaces = 2}) data, marshal_err := json.marshal(
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) x25519_pairs, pairs_ok := ssh_to_x25519(keys, context.temp_allocator)
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) secret_ct := make([]u8, ct_len, context.temp_allocator)
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) entries := make([]RecipientEntry, num_recipients, context.temp_allocator)
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,8 +126,6 @@ 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
} }
@@ -176,11 +174,10 @@ 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) x25519_pairs, pairs_ok := ssh_to_x25519(keys, context.temp_allocator)
if !pairs_ok { if !pairs_ok {
return return
} }
defer delete(x25519_pairs)
found := false found := false
matched_pi := 0 matched_pi := 0
@@ -272,33 +269,39 @@ decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: b
return return
} }
ssh_to_x25519 :: proc(keys: []SshKeyPair) -> (pairs: []X25519Keypair, ok: bool) { ssh_to_x25519 :: proc(
keys: []SshKeyPair,
allocator := context.temp_allocator,
) -> (
[]X25519Keypair,
bool,
) {
if len(keys) == 0 { if len(keys) == 0 {
return return {}, false
} }
pairs = make([]X25519Keypair, len(keys)) pairs := make([]X25519Keypair, len(keys), allocator)
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 return pairs, false
} }
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 return pairs, false
} }
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 return pairs, false
} }
ed25519_sk: [64]u8 ed25519_sk: [64]u8
@@ -313,11 +316,10 @@ ssh_to_x25519 :: proc(keys: []SshKeyPair) -> (pairs: []X25519Keypair, ok: bool)
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 return pairs, false
} }
} }
ok = true return pairs, 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) hex_bytes, _ := hex.encode(digest, context.temp_allocator)
return EnvFile { return EnvFile {
Path = abs_path, Path = abs_path,
@@ -485,11 +485,16 @@ 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) {
// TODO: Should thread the allocator through, right? roots, ok := find_git_roots(d.cfg)
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
@@ -505,7 +510,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 = matched_dir f.Dir, _ = strings.clone(matched_dir, allocator)
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
@@ -545,6 +550,7 @@ 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,7 +309,6 @@ 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, age encrypted database. envr keeps your .env synced to a local, 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 ~/data.age All your data is stored in ~/.envr/data.envr
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,6 +79,13 @@
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, 0, len(options)) selected = make([dynamic]bool, len(options))
cursor: int = 0 cursor: int = 0
scroll_offset: int = 0 scroll_offset: int = 0

View File

@@ -4,6 +4,7 @@ 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)
@@ -45,14 +46,38 @@ 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) { cell :: proc(b: ^strings.Builder, s: string, width: int, color: string = "", center := false) {
extra := len(s) - strings.rune_count(s) before: int
fmt.sbprintf(b, " %-*s \u2502", width + extra, s) after: int
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]) cell(&b, headers[i], col_widths[i], ansi.FG_BRIGHT_GREEN, true)
} }
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,6 +3,7 @@ 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)
@@ -116,13 +117,30 @@ test_render_table_normal :: proc(t: ^testing.T) {
output := strings.to_string(b) output := strings.to_string(b)
expected := `┌──────┬─────────────────────────┐ g := ansi.CSI + ansi.FG_BRIGHT_GREEN + ansi.SGR
│ Name │ Path │ r := ANSI_RESET
├──────┼─────────────────────────┤ n := ansi.CSI + ansi.SGR
│ foo │ /home/user/.env │
│ bar │ /home/user/project/.env │ expected := fmt.tprintf(
─────────────────────────────── "┌───────────────────────────────┐\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,
@@ -148,11 +166,17 @@ test_render_table_empty :: proc(t: ^testing.T) {
output := strings.to_string(b) output := strings.to_string(b)
expected := `┌──────┐ g := ansi.CSI + ansi.FG_BRIGHT_GREEN + ansi.SGR
│ Name │ r := ANSI_RESET
├──────┤
└──────┘ expected := fmt.tprintf(
` "┌──────┐\n" +
"│ %sName%s │\n" +
"├──────┤\n" +
"└──────┘\n",
g,
r,
)
testing.expect( testing.expect(
t, t,
output == expected, output == expected,
@@ -178,13 +202,30 @@ test_render_table_unicode :: proc(t: ^testing.T) {
output := strings.to_string(b) output := strings.to_string(b)
expected := `┌─────────────┬────────┐ g := ansi.CSI + ansi.FG_BRIGHT_GREEN + ansi.SGR
│ Status │ Detail │ r := ANSI_RESET
├─────────────┼────────┤ n := ansi.CSI + ansi.SGR
│ ✓ Available │ ok │
│ ✗ Missing │ fail │ expected := fmt.tprintf(
───────────────────── "┌─────────────────────┐\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,