45 Commits

Author SHA1 Message Date
Spencer Brower
7df83b064e chore(odin): release 0.3.0 2026-06-16 11:39:29 -04:00
fe2b256bd6 feat: All encryption/decryption now happens in-memory.
Release-as: v0.3.0
2026-06-16 11:38:20 -04:00
41decd9cdb ci: Updated release-please. 2026-06-16 11:36:05 -04:00
397f45d4d0 chore: Completed todos. 2026-06-16 11:36:05 -04:00
73a41830d1 docs: Removed completed TODOs. 2026-06-16 11:36:05 -04:00
e17d04c93d test: Table tests now view full output. 2026-06-16 11:36:05 -04:00
4600c81401 test: commands now accept stdout/stderr fields. 2026-06-16 11:36:05 -04:00
ec96dff055 chore: Cleaned up code. 2026-06-16 11:36:05 -04:00
4a26ee8145 feat: Config can be loaded from any path with --config-file (-c) flag. 2026-06-16 11:36:05 -04:00
e23ea960d7 test: Added missing tests. 2026-06-16 11:36:04 -04:00
3db86f0d2e refactor: Fixed cli command. 2026-06-16 11:36:04 -04:00
567cc8b1e2 tests: Added plan for improving testing. 2026-06-16 11:36:04 -04:00
fe3253f274 refactor: Fixed duplicate terminal checks. 2026-06-16 11:36:04 -04:00
f6ffeeee65 docs: Created table improvement plan. 2026-06-16 11:36:04 -04:00
23d5ff5e01 style: Removed unused code. 2026-06-16 11:36:04 -04:00
4599b25b1b refactor: Removed duplicate insert calls. 2026-06-16 11:36:04 -04:00
e32f0ea6d2 refactor: Fixed logic bug in db. 2026-06-16 11:36:03 -04:00
650c91d51b ci: Updated release-please. 2026-06-16 11:36:03 -04:00
ad3ce748bb docs: updated README.md 2026-06-16 11:36:03 -04:00
930c3d4c5d ci: Updated github action. 2026-06-16 11:36:03 -04:00
b1b0449b7b refactor: Removed go code. 2026-06-16 11:36:03 -04:00
0a74b0dbcc build: Converted Makefile and flake package. 2026-06-16 11:36:03 -04:00
d56f11250c refactor: removed is_tty. 2026-06-16 11:36:03 -04:00
23b8c2dc67 feat: Switched from age to libsodium.
This means, fewer dependencies, a smaller binary, and more secure data.

BREAKING CHANGE: The encryption format of databases has changed. Age
encryption is no longer supported, and no automatic migration path was
implemented.
2026-06-16 11:34:36 -04:00
2f4a7887ea docs: Updated TODOs. 2026-06-16 10:48:11 -04:00
5eee6cd6ea refactor(odin): Migrated nushell-completion command to go. 2026-06-16 10:48:10 -04:00
67f735a654 test: Added tests. 2026-06-16 10:48:10 -04:00
7d16dae4f4 refactor: Fixed the rest of the (tested) leaks. 2026-06-16 10:48:10 -04:00
365e9149b1 perf: Improved writer performance. 2026-06-16 10:48:10 -04:00
1068458f32 refactor: Fixed a number of memory leaks. 2026-06-16 10:48:10 -04:00
22a517340a refactor(odin): Added proper help text to all commands. 2026-06-16 10:48:10 -04:00
fcee4ca7b1 refactor: Got rid of go fallback code. 2026-06-16 10:48:10 -04:00
dff5235d65 refactor: Fixed memory leaks in find_binary. 2026-06-16 10:48:09 -04:00
5865315161 refactor(odin): Ported init command. 2026-06-16 10:48:09 -04:00
191ba305ef refactor(odin): Ported scan command. 2026-06-16 10:48:09 -04:00
d890c88b6d refactor(odin): port check command. 2026-06-16 10:48:09 -04:00
f8add2ad22 refactor(odin): Fixed AI mistakes. 2026-06-16 10:47:57 -04:00
2de7e20f5c refactor(odin): ported edit-config command. 2026-06-16 10:22:25 -04:00
8dd6b17cb9 refactor(odin): Ported restore command. 2026-06-16 10:22:08 -04:00
83b940337c refactor(odin): Ported remove command. 2026-06-16 10:21:46 -04:00
83a8caf691 refactor(odin): Added long text and --help flags. 2026-06-16 10:21:24 -04:00
1964698e35 refactor(odin): ported backup command. 2026-06-16 10:18:56 -04:00
de2186a2e5 refactor(odin): ported list command. 2026-06-16 10:18:21 -04:00
cb51a398ad refactor(odin): ported deps command, added utilities (features, tty, table). 2026-06-16 10:18:05 -04:00
e989b88303 refactor: scaffolded odin project with CLI parser, version command, Go fallback 2026-06-16 10:17:12 -04:00
29 changed files with 632 additions and 532 deletions

View File

@@ -25,3 +25,4 @@ jobs:
# this is a built-in strategy in release-please, see "Action Inputs" # this is a built-in strategy in release-please, see "Action Inputs"
# for more options # for more options
release-type: simple release-type: simple
target-branch: ${{ github.ref_name }}

View File

@@ -1,5 +1,23 @@
# Changelog # Changelog
## [0.3.0](https://github.com/sbrow/envr/compare/v0.2.1...v0.3.0) (2026-06-16)
### ⚠ BREAKING CHANGES
* The encryption format of databases has changed. Age encryption is no longer supported, and no automatic migration path was implemented.
### Features
* All encryption/decryption now happens in-memory. ([fe2b256](https://github.com/sbrow/envr/commit/fe2b256bd61eaf551d53faf3893b473a64a94667))
* Config can be loaded from any path with `--config-file (-c)` flag. ([4a26ee8](https://github.com/sbrow/envr/commit/4a26ee814591e6aab0eb99d2359d51b31011edfe))
* Switched from age to libsodium. ([23b8c2d](https://github.com/sbrow/envr/commit/23b8c2dc671a23cf76cf6746b33806ded9381486))
### Performance Improvements
* Improved writer performance. ([365e914](https://github.com/sbrow/envr/commit/365e9149b1a738ac9119bb5f74dc7e047ecfed5b))
## [0.2.1](https://github.com/sbrow/envr/compare/v0.2.0...v0.2.1) (2026-01-12) ## [0.2.1](https://github.com/sbrow/envr/compare/v0.2.0...v0.2.1) (2026-01-12)

View File

@@ -1,70 +1,47 @@
# TODO # TODOs
Note: These todos can wait until all the subcommands have been ported.
## HIGH 1. Consider giving db its own allocator
2. **db.odin:380-383, 405, 446**`sqlite.bind_text` return values overwritten but never checked. A failed bind means `sqlite.step` operates on unbound params. 2. **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. **config.odin:52-54**`os.user_home_dir` error silently ignored. If it fails, `home` is `""` and all paths become relative (`".envr"` instead of `"~/.envr"`). 3. **db.odin:135, 250** — String interpolation into SQL (`VACUUM INTO '%s'`, `ATTACH DATABASE '%s'`). Currently safe because input is controlled, but fragile.
## MEDIUM 4. **features.odin:30-41**`find_binary` uses `strings.join` instead of `filepath.join`, uses `os.stat` instead of checking executability, hardcodes `:` as PATH separator (wrong on Windows).
4. **db.odin:29-35**`make_temp_path` never calls `strings.builder_destroy`. Leaks builder buffer every call. 5. **cmd_restore.odin:20-30 & cmd_remove.odin:19-29** — Identical path-resolution block copy-pasted. `is_abs` guard is redundant since `filepath.abs` is a no-op on absolute paths. Extract a helper.
5. **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. 6. **cmd_restore.odin:44**`os.mkdir_all` error silently discarded. Subsequent write failure will be confusing.
6. **db.odin:470-473**`string_to_cstring` allocates via `strings.clone_to_cstring` and never frees. Called dozens of times across db operations. 8. **config.odin:178**`search_paths` silently ignores `os.user_home_dir` error. If home is empty, `~` isn't expanded. Same class of bug as issue 3.
7. **db.odin:470, 462** — Both `string_to_cstring` and `cstring_to_string` ignore allocation errors. A nil cstring gets passed to SQLite (UB). 10. **db.odin:115**`json.unmarshal_string` error not checked. Malformed JSON silently produces empty/partial data.
8. **db.odin:135, 250**String interpolation into SQL (`VACUUM INTO '%s'`, `ATTACH DATABASE '%s'`). Currently safe because input is controlled, but fragile. 11. **db.odin:352-353**`hex.encode` error ignored. `string(hex_bytes)` aliases the byte slice.
9. **features.odin:30-41**`find_binary` uses `strings.join` instead of `filepath.join`, uses `os.stat` instead of checking executability, hardcodes `:` as PATH separator (wrong on Windows). 12. **cmd_sync.odin:80, cmd_list.odin:33, cmd_deps.odin:9**`make([]string, 2)` for table rows never freed. Leaks per row. Defer to memory pass.
10. **cmd_restore.odin:20-30 & cmd_remove.odin:19-29** — Identical path-resolution block copy-pasted. `is_abs` guard is redundant since `filepath.abs` is a no-op on absolute paths. Extract a helper. 13. **cmd_list.odin** — Non-TTY branch builds `ListEntry` structs and marshals JSON separately. Now that `render_json_rows` (issue 1) accepts an `io.Writer` and uses `json.marshal`, unify both branches to use it. Note: will change JSON keys from `"directory"/"path"` to `"Directory"/"Path"`.
11. **cmd_restore.odin:44**`os.mkdir_all` error silently discarded. Subsequent write failure will be confusing. 14. Check for prealloc opportunities. i.e. `make([dynamic]string)` -> `make([dynamic]string, 5)`.
12. **cmd_edit_config.odin:27**`$EDITOR` used as single binary name. Breaks for multi-word values like `"code -w"`. Needs `strings.fields()`. 15. Add a text filter to the multi_select.
33. **config.odin:178**`search_paths` silently ignores `os.user_home_dir` error. If home is empty, `~` isn't expanded. Same class of bug as issue 3. 16. Create backup / fallback fd.
35. **prompt.odin:124**`make([dynamic]bool, len(options))` creates N zero-initialized elements. Works because `false` is the default, but same footgun as original issue 1. Should be `make([dynamic]bool, 0, len(options))`. 17. Add tests for untested commands.
39. Lots of memory leaks to fix. 18. 2 scan tests silently skip when fd isn't installed, tests pass without actually testing anything. These should use #assert to be sure that fd is in path.
## LOW 19. Try to do all encryption / decryption in memory - only read / write encrypted data to disk.
15. **db.odin:115**`json.unmarshal_string` error not checked. Malformed JSON silently produces empty/partial data. 20. add --format -f flag to commands that draw tables.
16. **db.odin:352-353**`hex.encode` error ignored. `string(hex_bytes)` aliases the byte slice. 21. Replace `testing.expect` calls with `testing.expect_value` calls where appropriate.
18. **config.odin:51-60**`envr_dir` recomputes home dir on every call. Could cache. 22. Change struct field names from PascalCase to snake_case.
37. **cmd_sync.odin:80, cmd_list.odin:33, cmd_deps.odin:9**`make([]string, 2)` for table rows never freed. Leaks per row. Defer to memory pass. 23. procedures should be ordered by use, main at the top, then in the order they are called from main.
## REFACTOR
20. **cmd_list.odin** — Non-TTY branch builds `ListEntry` structs and marshals JSON separately. Now that `render_json_rows` (issue 1) accepts an `io.Writer` and uses `json.marshal`, unify both branches to use it. Note: will change JSON keys from `"directory"/"path"` to `"Directory"/"Path"`.
21. Check for prealloc opportunities. i.e. `make([dynamic]string)` -> `make([dynamic]string, 5)`.
23. Add a text filter to the multi_select.
24. Create backup / fallback fd.
25. Add tests for untested commands.
28. 2 scan tests silently skip when fd isn't installed, tests pass without actually testing anything. These should use #assert to be sure that fd is in path.
38. Try to do all encryption / decryption in memory - only read / write encrypted data to disk.
40. use a buffered writer where possible (mem.DEFAULT_PAGE_SIZE)
41. add --format -f flag to commands that draw tables.
42. Replace `testing.expect` calls with `testing.expect_value` calls where appropriate.
## Double-check AI output ## Double-check AI output

View File

@@ -3,7 +3,6 @@ package main
import "core:bufio" import "core:bufio"
import "core:fmt" import "core:fmt"
import "core:io" import "core:io"
import "core:mem"
import "core:os" import "core:os"
import "core:strings" import "core:strings"
@@ -13,6 +12,9 @@ Command :: struct {
flags: map[string]string, flags: map[string]string,
bool_set: map[string]bool, bool_set: map[string]bool,
config_path: string, config_path: string,
out_buf: ^bufio.Writer,
out: io.Writer,
err: io.Writer,
} }
CommandInfo :: struct { CommandInfo :: struct {
@@ -28,7 +30,10 @@ COMMANDS := []CommandInfo {
"init", "init",
"envr init", "envr init",
"Set up envr", "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
~/.envr/config in JSON format.\n\nDuring setup, you will be prompted to select one or more ssh keys with which to
encrypt your databse. **Make 100% sure** that you have **a remote copy** of this
key 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", "", {}},
@@ -56,11 +61,27 @@ COMMANDS := []CommandInfo {
}, },
} }
delete_command :: proc(cmd: ^Command) {
delete(cmd.args)
delete(cmd.flags)
delete(cmd.bool_set)
bufio.writer_destroy(cmd.out_buf)
free(cmd.out_buf)
}
// Caller is responsible for calling delete_command(cmd).
// FIXME: Works in kinda a wonky and awkward way. // FIXME: Works in kinda a wonky and awkward way.
parse_args :: proc(args: []string) -> (cmd: Command, ok: bool) { parse_args :: proc(args: []string, out: io.Stream, err: io.Stream) -> (cmd: Command, ok: bool) {
{
cmd.out_buf = new(bufio.Writer)
bufio.writer_init(cmd.out_buf, out)
cmd.out = bufio.writer_to_writer(cmd.out_buf)
cmd.err = err
}
if len(args) < 2 || args[1] == "--help" || args[1] == "-h" { if len(args) < 2 || args[1] == "--help" || args[1] == "-h" {
print_usage() write_usage(cmd.out)
return Command{}, false return cmd, false
} }
cmd.name = args[1] cmd.name = args[1]
@@ -102,13 +123,15 @@ parse_args :: proc(args: []string) -> (cmd: Command, ok: bool) {
cmd.config_path = val cmd.config_path = val
} else { } else {
// FIXME: Handle err // FIXME: Handle err
home, _ := os.user_home_dir(context.allocator) // TODO: Is this right?
cmd.config_path = default_config_path(home) home, _ := os.user_home_dir(context.temp_allocator)
// TODO: should we copy out of the temp_allocator?
cmd.config_path = default_config_path(home, context.temp_allocator)
} }
if has_flag(&cmd, "help") { if has_flag(&cmd, "help") {
print_command_help(cmd.name) print_command_help(&cmd)
return Command{}, false return cmd, false
} }
return cmd, true return cmd, true
@@ -167,18 +190,12 @@ write_command_help :: proc(name: string, w: io.Writer) -> bool {
return true return true
} }
print_command_help :: proc(name: string) { print_command_help :: proc(cmd: ^Command) {
bw: bufio.Writer ok := write_command_help(cmd.name, cmd.out)
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.wprintf(cmd.err, "Unknown command: %s\n", cmd.name)
print_usage() write_usage(cmd.out)
} }
bufio.writer_flush(&bw)
} }
// TODO: command args should be shown in usage. // TODO: command args should be shown in usage.
@@ -253,13 +270,3 @@ Use "envr [command] --help" for more information about a command.
) )
} }
// TODO: Look at usages,might want to pass a writer
print_usage :: proc() {
bw: bufio.Writer
bufio.writer_init(&bw, io.to_writer(os.to_writer(os.stdout)), mem.DEFAULT_PAGE_SIZE)
defer bufio.writer_destroy(&bw)
defer bufio.writer_flush(&bw)
write_usage(bufio.writer_to_writer(&bw))
}

View File

@@ -2,6 +2,7 @@
package main package main
import "core:bufio" import "core:bufio"
import "core:fmt"
import "core:strings" import "core:strings"
import "core:testing" import "core:testing"
@@ -189,29 +190,50 @@ test_has_flag_empty_command :: proc(t: ^testing.T) {
} }
test_parse_args :: proc( test_parse_args :: proc(
args: []string,
) -> (
cmd: Command,
ok: bool,
out_text: string,
err_text: string,
) {
out_b: strings.Builder
strings.builder_init(&out_b)
defer strings.builder_destroy(&out_b)
err_b: strings.Builder
strings.builder_init(&err_b)
defer strings.builder_destroy(&err_b)
cmd, ok = parse_args(args, strings.to_stream(&out_b), strings.to_stream(&err_b))
if ok {
bufio.writer_flush(cmd.out_buf)
out_text = strings.to_string(out_b)
err_text = strings.to_string(err_b)
}
return
}
@(test)
test_parse_args_bare_command :: proc(t: ^testing.T) { test_parse_args_bare_command :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list"}) cmd, ok, _, _ := test_parse_args([]string{"envr", "list"})
testing.expect(t, ok, "should succeed") testing.expect(t, ok, "should succeed")
if !ok do return if !ok do return
defer delete_command(&cmd) defer delete_command(&cmd)
defer delete(cmd.flags)
defer delete(cmd.bool_set)
testing.expect_value(t, cmd.name, "list") testing.expect_value(t, cmd.name, "list")
testing.expect(t, len(cmd.args) == 0, "should have no positional args") testing.expect_value(t, len(cmd.args), 0)
testing.expect(t, len(cmd.flags) == 0, "should have no flags") testing.expect_value(t, len(cmd.flags), 0)
testing.expect(t, len(cmd.bool_set) == 0, "should have no bool flags") testing.expect_value(t, len(cmd.bool_set), 0)
} }
@(test) @(test)
test_parse_args_positional :: proc(t: ^testing.T) { test_parse_args_positional :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "backup", "/project/.env"}) cmd, ok, _, _ := test_parse_args([]string{"envr", "backup", "/project/.env"})
testing.expect(t, ok, "should succeed") defer delete_command(&cmd)
testing.expect(t, ok, "should succeed")
defer delete(cmd.args)
defer delete(cmd.flags)
defer delete(cmd.bool_set)
testing.expect(t, cmd.name == "backup") testing.expect(t, cmd.name == "backup")
testing.expect(t, len(cmd.args) == 1) testing.expect(t, len(cmd.args) == 1)
testing.expect(t, cmd.args[0] == "/project/.env") testing.expect(t, cmd.args[0] == "/project/.env")
@@ -220,60 +242,50 @@ test_parse_args_positional :: proc(t: ^testing.T) {
@(test) @(test)
test_parse_args_long_flag_with_value :: proc(t: ^testing.T) { test_parse_args_long_flag_with_value :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "sync", "--config", "x.json"}) cmd, ok, _, _ := test_parse_args([]string{"envr", "sync", "--config", "x.json"})
testing.expect(t, ok, "should succeed") testing.expect(t, ok, "should succeed")
if !ok do return if !ok do return
defer delete_command(&cmd) defer delete_command(&cmd)
defer delete(cmd.flags)
defer delete(cmd.bool_set)
testing.expect(t, cmd.flags["config"] == "x.json") testing.expect(t, cmd.flags["config"] == "x.json")
} }
@(test) @(test)
test_parse_args_short_flag_with_value :: proc(t: ^testing.T) { test_parse_args_short_flag_with_value :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "sync", "-c", "x.json"}) cmd, ok, _, _ := test_parse_args([]string{"envr", "sync", "-c", "x.json"})
testing.expect(t, ok, "should succeed") testing.expect(t, ok, "should succeed")
if !ok do return if !ok do return
defer delete_command(&cmd) defer delete_command(&cmd)
defer delete(cmd.flags)
defer delete(cmd.bool_set)
testing.expect(t, cmd.flags["c"] == "x.json") testing.expect(t, cmd.flags["c"] == "x.json")
} }
@(test) @(test)
test_parse_args_long_bool_flag :: proc(t: ^testing.T) { test_parse_args_long_bool_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "init", "--force"}) cmd, ok, _, _ := test_parse_args([]string{"envr", "init", "--force"})
testing.expect(t, ok, "should succeed") testing.expect(t, ok, "should succeed")
if !ok do return if !ok do return
defer delete_command(&cmd) defer delete_command(&cmd)
defer delete(cmd.flags)
defer delete(cmd.bool_set)
testing.expect(t, cmd.bool_set["force"] == true) testing.expect(t, cmd.bool_set["force"] == true)
} }
@(test) @(test)
test_parse_args_short_bool_flag :: proc(t: ^testing.T) { test_parse_args_short_bool_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "version", "-l"}) cmd, ok, _, _ := test_parse_args([]string{"envr", "version", "-l"})
testing.expect(t, ok, "should succeed") testing.expect(t, ok, "should succeed")
if !ok do return if !ok do return
defer delete_command(&cmd) defer delete_command(&cmd)
defer delete(cmd.flags)
defer delete(cmd.bool_set)
testing.expect(t, cmd.bool_set["l"] == true) testing.expect(t, cmd.bool_set["l"] == true)
} }
@(test) @(test)
test_parse_args_multiple_positionals :: proc(t: ^testing.T) { test_parse_args_multiple_positionals :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "backup", "a", "b"}) cmd, ok, _, _ := test_parse_args([]string{"envr", "backup", "a", "b"})
testing.expect(t, ok, "should succeed") testing.expect(t, ok, "should succeed")
if !ok do return if !ok do return
defer delete_command(&cmd) defer delete_command(&cmd)
defer delete(cmd.flags)
defer delete(cmd.bool_set)
testing.expect(t, len(cmd.args) == 2) testing.expect(t, len(cmd.args) == 2)
testing.expect(t, cmd.args[0] == "a") testing.expect(t, cmd.args[0] == "a")
testing.expect(t, cmd.args[1] == "b") testing.expect(t, cmd.args[1] == "b")
@@ -282,12 +294,10 @@ test_parse_args_multiple_positionals :: proc(t: ^testing.T) {
@(test) @(test)
test_parse_args_mixed_flags_and_positionals :: proc(t: ^testing.T) { test_parse_args_mixed_flags_and_positionals :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "backup", "/project/.env", "--force"}) cmd, ok, _, _ := test_parse_args([]string{"envr", "backup", "/project/.env", "--force"})
testing.expect(t, ok, "should succeed") testing.expect(t, ok, "should succeed")
if !ok do return if !ok do return
defer delete_command(&cmd) defer delete_command(&cmd)
defer delete(cmd.flags)
defer delete(cmd.bool_set)
testing.expect(t, cmd.bool_set["force"] == true) testing.expect(t, cmd.bool_set["force"] == true)
testing.expect(t, len(cmd.args) == 1) testing.expect(t, len(cmd.args) == 1)
testing.expect(t, cmd.args[0] == "/project/.env") testing.expect(t, cmd.args[0] == "/project/.env")
@@ -296,18 +306,16 @@ test_parse_args_mixed_flags_and_positionals :: proc(t: ^testing.T) {
@(test) @(test)
test_parse_args_no_args :: proc(t: ^testing.T) { test_parse_args_no_args :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr"}) cmd, ok, _, _ := test_parse_args([]string{"envr"})
testing.expect(t, !ok, "no args should return false") defer delete_command(&cmd)
testing.expect(t, !ok, "no args should return false")
} }
@(test) @(test)
test_parse_args_flag_then_positional_then_flag :: proc(t: ^testing.T) { test_parse_args_flag_then_positional_then_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "backup", "a.env", "--force", "--verbose"}) cmd, ok, _, _ := test_parse_args([]string{"envr", "backup", "a.env", "--force", "--verbose"})
testing.expect(t, ok, "should succeed") defer delete_command(&cmd)
testing.expect(t, ok, "should succeed")
defer delete(cmd.args)
defer delete(cmd.flags)
defer delete(cmd.bool_set)
testing.expect(t, cmd.bool_set["force"] == true) testing.expect(t, cmd.bool_set["force"] == true)
testing.expect(t, cmd.bool_set["verbose"] == true) testing.expect(t, cmd.bool_set["verbose"] == true)
testing.expect(t, len(cmd.args) == 1) testing.expect(t, len(cmd.args) == 1)
@@ -317,36 +325,40 @@ test_parse_args_flag_then_positional_then_flag :: proc(t: ^testing.T) {
@(test) @(test)
test_parse_args_config_file_long_flag :: proc(t: ^testing.T) { test_parse_args_config_file_long_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args( cmd, ok, _, _ := test_parse_args(
testing.expect(t, ok, "should succeed") []string{"envr", "list", "--config-file", "/custom/config.json"},
)
testing.expect(t, ok, "should succeed")
if !ok do return if !ok do return
defer delete_command(&cmd) defer delete_command(&cmd)
defer delete(cmd.flags)
defer delete(cmd.bool_set)
testing.expect( testing.expect(
} t,
cmd.config_path == "/custom/config.json",
"config_path should be set from --config-file",
)
}
@(test) @(test)
test_parse_args_config_file_short_flag :: proc(t: ^testing.T) { test_parse_args_config_file_short_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "-c", "/custom/config.json"}) cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "-c", "/custom/config.json"})
testing.expect(t, ok, "should succeed") testing.expect(t, ok, "should succeed")
if !ok do return if !ok do return
defer delete_command(&cmd) defer delete_command(&cmd)
defer delete(cmd.flags)
defer delete(cmd.bool_set)
testing.expect( testing.expect(
} t,
cmd.config_path == "/custom/config.json",
"config_path should be set from -c",
)
}
@(test) @(test)
test_parse_args_config_file_defaults :: proc(t: ^testing.T) { test_parse_args_config_file_defaults :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list"}) cmd, ok, _, _ := test_parse_args([]string{"envr", "list"})
testing.expect(t, ok, "should succeed") testing.expect(t, ok, "should succeed")
if !ok do return if !ok do return
defer delete_command(&cmd) defer delete_command(&cmd)
defer delete(cmd.flags)
defer delete(cmd.bool_set)
testing.expect(t, len(cmd.config_path) > 0, "config_path should default to non-empty path") testing.expect(t, len(cmd.config_path) > 0, "config_path should default to non-empty path")
testing.expect( testing.expect(
t, t,

View File

@@ -5,13 +5,13 @@ import "core:strings"
cmd_backup :: proc(cmd: ^Command) { cmd_backup :: proc(cmd: ^Command) {
if len(cmd.args) != 1 { if len(cmd.args) != 1 {
print_command_help("backup") print_command_help(cmd)
return return
} }
path := cmd.args[0] path := cmd.args[0]
if len(strings.trim_space(path)) == 0 { if len(strings.trim_space(path)) == 0 {
fmt.println("Error: No path provided") fmt.wprintln(cmd.err, "Error: No path provided", flush = false)
return return
} }
@@ -30,6 +30,6 @@ cmd_backup :: proc(cmd: ^Command) {
return return
} }
fmt.printf("Saved %s into the database\n", path) fmt.wprintf(cmd.out, "Saved %s into the database\n", path, flush = false)
} }

View File

@@ -13,7 +13,7 @@ cmd_check :: proc(cmd: ^Command) {
} else { } else {
cwd, cwd_err := os.get_working_directory(context.temp_allocator) cwd, cwd_err := os.get_working_directory(context.temp_allocator)
if cwd_err != nil { if cwd_err != nil {
fmt.printf("Error getting current directory: %v\n", cwd_err) fmt.wprintf(cmd.err, "Error getting current directory: %v\n", cwd_err, flush = false)
return return
} }
check_path = cwd check_path = cwd
@@ -25,7 +25,7 @@ cmd_check :: proc(cmd: ^Command) {
} else { } else {
resolved, abs_err := filepath.abs(check_path) resolved, abs_err := filepath.abs(check_path)
if abs_err != nil { if abs_err != nil {
fmt.printf("Error getting absolute path: %v\n", abs_err) fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return return
} }
abs_path = resolved abs_path = resolved
@@ -43,15 +43,17 @@ cmd_check :: proc(cmd: ^Command) {
if is_dir { if is_dir {
if cant_scan(feats) { if cant_scan(feats) {
fmt.println( fmt.wprintln(
cmd.err,
"Error: please install fd to use the check command (https://github.com/sharkdp/fd)", "Error: please install fd to use the check command (https://github.com/sharkdp/fd)",
flush = false,
) )
return return
} }
scanned, scan_ok := scan_path(abs_path, db.cfg) scanned, scan_ok := scan_path(abs_path, db.cfg)
if !scan_ok { if !scan_ok {
fmt.println("Error scanning directory for .env files") fmt.wprintln(cmd.err, "Error scanning directory for .env files", flush = false)
return return
} }
files_in_path = scanned files_in_path = scanned
@@ -68,16 +70,15 @@ cmd_check :: proc(cmd: ^Command) {
if len(not_backed) == 0 { if len(not_backed) == 0 {
if len(files_in_path) == 0 { if len(files_in_path) == 0 {
fmt.println("No .env files found in the specified directory.") fmt.wprintln(cmd.out, "No .env files found in the specified directory.", flush = false)
} else { } else {
fmt.println("✓ All .env files in the directory are backed up.") fmt.wprintln(cmd.out, "✓ All .env files in the directory are backed up.", flush = false)
} }
} else { } else {
fmt.printf("Found %d .env file(s) that are not backed up:\n", len(not_backed)) fmt.wprintf(cmd.out, "Found %d .env file(s) that are not backed up:\n", len(not_backed), flush = false)
for file in not_backed { for file in not_backed {
fmt.printf(" %s\n", file) fmt.wprintf(cmd.out, " %s\n", file, flush = false)
} }
fmt.println("\nRun 'envr sync' to back up these files.") fmt.wprintln(cmd.out, "\nRun 'envr sync' to back up these files.", flush = false)
} }
} }

View File

@@ -1,6 +1,6 @@
package main package main
import "core:io" import "core:fmt"
import "core:os" import "core:os"
import "core:terminal" import "core:terminal"
@@ -24,12 +24,10 @@ cmd_deps :: proc(cmd: ^Command) {
} }
if terminal.is_terminal(os.stdout) { if terminal.is_terminal(os.stdout) {
w := io.to_writer(os.to_writer(os.stdout)) render_table(cmd.out, headers, rows[:])
render_table(w, headers, rows[:])
} else { } else {
w := io.to_writer(os.to_writer(os.stdout)) render_json_rows(cmd.out, headers, rows[:])
render_json_rows(w, headers, rows[:]) fmt.wprint(cmd.out, "\n", flush = false)
io.write_string(w, "\n")
} }
} }

View File

@@ -6,7 +6,7 @@ import "core:os"
cmd_edit_config :: proc(cmd: ^Command) { cmd_edit_config :: proc(cmd: ^Command) {
editor := os.get_env("EDITOR", context.allocator) editor := os.get_env("EDITOR", context.allocator)
if len(editor) == 0 { if len(editor) == 0 {
fmt.println("Error: $EDITOR environment variable is not set") fmt.wprintln(cmd.err, "Error: $EDITOR environment variable is not set", flush = false)
return return
} }
@@ -14,7 +14,12 @@ cmd_edit_config :: proc(cmd: ^Command) {
_, stat_err := os.stat(config_path, context.allocator) _, stat_err := os.stat(config_path, context.allocator)
if stat_err != nil { if stat_err != nil {
fmt.printf("Config file does not exist at %s. Run 'envr init' first.\n", config_path) fmt.wprintf(
cmd.err,
"Config file does not exist at %s. Run 'envr init' first.\n",
config_path,
flush = false,
)
return return
} }
@@ -28,13 +33,13 @@ cmd_edit_config :: proc(cmd: ^Command) {
p, start_err := os.process_start(desc) p, start_err := os.process_start(desc)
if start_err != nil { if start_err != nil {
fmt.printf("Error running editor: %v\n", start_err) fmt.wprintf(cmd.err, "Error running editor: %v\n", start_err, flush = false)
return return
} }
state, wait_err := os.process_wait(p) state, wait_err := os.process_wait(p)
if wait_err != nil { if wait_err != nil {
fmt.printf("Error waiting for editor: %v\n", wait_err) fmt.wprintf(cmd.err, "Error waiting for editor: %v\n", wait_err, flush = false)
return return
} }
if state.exit_code != 0 { if state.exit_code != 0 {

View File

@@ -5,13 +5,15 @@ import "core:fmt"
cmd_init :: proc(cmd: ^Command) { cmd_init :: proc(cmd: ^Command) {
force := has_flag(cmd, "force") || has_flag(cmd, "f") force := has_flag(cmd, "force") || has_flag(cmd, "f")
fmt.println(cmd.config_path) fmt.wprintln(cmd.out, cmd.config_path, flush = false)
_, cfg_exists := load_config(cmd.config_path) _, cfg_exists := load_config(cmd.config_path)
if cfg_exists && !force { if cfg_exists && !force {
fmt.println( fmt.wprintln(
cmd.out,
`You have already initialized envr. `You have already initialized envr.
Run again with the --force flag if you want to reinitialize.`, Run again with the --force flag if you want to reinitialize.`,
flush = false,
) )
return return
} }
@@ -22,15 +24,15 @@ Run again with the --force flag if you want to reinitialize.`,
} }
if len(keys) == 0 { if len(keys) == 0 {
fmt.println(`No ssh-ed25519 keys found in ~/.ssh fmt.wprintln(cmd.err, `No ssh-ed25519 keys found in ~/.ssh
Generate one with: ssh-keygen -t ed25519`) Generate one with: ssh-keygen -t ed25519`, flush = false)
return return
} }
selected, result := multi_select("Select SSH private keys:", keys[:]) selected, result := multi_select("Select SSH private keys:", keys[:])
defer delete(selected) defer delete(selected)
if result == .Cancel { if result == .Cancel {
fmt.println("\x1b[2mCancelled.\x1b[0m") fmt.wprintln(cmd.out, "\x1b[2mCancelled.\x1b[0m", flush = false)
return return
} }
@@ -42,7 +44,7 @@ Generate one with: ssh-keygen -t ed25519`)
} }
if len(selected_paths) == 0 { if len(selected_paths) == 0 {
fmt.println("No SSH keys selected - Config not created") fmt.wprintln(cmd.err, "No SSH keys selected - Config not created", flush = false)
return return
} }
@@ -51,9 +53,10 @@ Generate one with: ssh-keygen -t ed25519`)
return return
} }
fmt.printf( fmt.wprintf(
cmd.out,
"Config initialized with %d SSH key(s). You are ready to use envr.\n", "Config initialized with %d SSH key(s). You are ready to use envr.\n",
len(selected_paths), len(selected_paths),
flush = false,
) )
} }

View File

@@ -2,7 +2,6 @@ package main
import "core:encoding/json" import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:io"
import "core:os" import "core:os"
import "core:path/filepath" import "core:path/filepath"
import "core:strings" import "core:strings"
@@ -41,8 +40,7 @@ cmd_list :: proc(cmd: ^Command) {
append(&table_rows, row_slice) append(&table_rows, row_slice)
} }
w := io.to_writer(os.to_writer(os.stdout)) render_table(cmd.out, headers, table_rows[:])
render_table(w, headers, table_rows[:])
} else { } else {
// TODO: Should we instead print full entries here? // TODO: Should we instead print full entries here?
entries: [dynamic]ListEntry entries: [dynamic]ListEntry
@@ -59,10 +57,10 @@ cmd_list :: proc(cmd: ^Command) {
data, marshal_err := json.marshal(entries[:], allocator = context.temp_allocator) data, marshal_err := json.marshal(entries[:], allocator = context.temp_allocator)
if marshal_err != nil { if marshal_err != nil {
fmt.printf("Error marshaling JSON: %v\n", marshal_err) fmt.wprintf(cmd.err, "Error marshaling JSON: %v\n", marshal_err, flush = false)
return return
} }
fmt.println(string(data)) fmt.wprintln(cmd.out, string(data), flush = false)
} }
} }

View File

@@ -5,7 +5,6 @@ import "core:fmt"
COMPLETION_SCRIPT: string : string(#load("mod.nu")) COMPLETION_SCRIPT: string : string(#load("mod.nu"))
cmd_nushell_completion :: proc(cmd: ^Command) { cmd_nushell_completion :: proc(cmd: ^Command) {
// TODO: Use buffered writer? fmt.wprint(cmd.out, COMPLETION_SCRIPT, flush = false)
fmt.print(COMPLETION_SCRIPT)
} }

View File

@@ -6,13 +6,13 @@ import "core:strings"
cmd_remove :: proc(cmd: ^Command) { cmd_remove :: proc(cmd: ^Command) {
if len(cmd.args) != 1 { if len(cmd.args) != 1 {
print_command_help("remove") print_command_help(cmd)
return return
} }
path := cmd.args[0] path := cmd.args[0]
if len(strings.trim_space(path)) == 0 { if len(strings.trim_space(path)) == 0 {
fmt.println("Error: No path provided") fmt.wprintln(cmd.err, "Error: No path provided", flush = false)
return return
} }
@@ -23,7 +23,7 @@ cmd_remove :: proc(cmd: ^Command) {
} else { } else {
resolved, abs_err := filepath.abs(path) resolved, abs_err := filepath.abs(path)
if abs_err != nil { if abs_err != nil {
fmt.printf("Error getting absolute path: %v\n", abs_err) fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return return
} }
abs_path = resolved abs_path = resolved
@@ -39,6 +39,6 @@ cmd_remove :: proc(cmd: ^Command) {
return return
} }
fmt.printf("Removed %s from the database\n", abs_path) fmt.wprintf(cmd.out, "Removed %s from the database\n", abs_path, flush = false)
} }

View File

@@ -7,13 +7,13 @@ import "core:strings"
cmd_restore :: proc(cmd: ^Command) { cmd_restore :: proc(cmd: ^Command) {
if len(cmd.args) != 1 { if len(cmd.args) != 1 {
print_command_help("restore") print_command_help(cmd)
return return
} }
path := cmd.args[0] path := cmd.args[0]
if len(strings.trim_space(path)) == 0 { if len(strings.trim_space(path)) == 0 {
fmt.println("Error: No path provided") fmt.wprintln(cmd.err, "Error: No path provided", flush = false)
return return
} }
@@ -24,7 +24,7 @@ cmd_restore :: proc(cmd: ^Command) {
} else { } else {
resolved, abs_err := filepath.abs(path) resolved, abs_err := filepath.abs(path)
if abs_err != nil { if abs_err != nil {
fmt.printf("Error getting absolute path: %v\n", abs_err) fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return return
} }
abs_path = resolved abs_path = resolved
@@ -46,10 +46,10 @@ cmd_restore :: proc(cmd: ^Command) {
write_err := os.write_entire_file(file.Path, file.contents) write_err := os.write_entire_file(file.Path, file.contents)
if write_err != nil { if write_err != nil {
fmt.printf("Error writing file: %v\n", write_err) fmt.wprintf(cmd.err, "Error writing file: %v\n", write_err, flush = false)
return return
} }
fmt.printf("Restored %s\n", file.Path) fmt.wprintf(cmd.out, "Restored %s\n", file.Path, flush = false)
} }

View File

@@ -8,8 +8,10 @@ import "core:terminal"
cmd_scan :: proc(cmd: ^Command) { cmd_scan :: proc(cmd: ^Command) {
feats := check_features() feats := check_features()
if cant_scan(feats) { if cant_scan(feats) {
fmt.println( fmt.wprintln(
cmd.err,
"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)",
flush = false,
) )
return return
} }
@@ -22,7 +24,11 @@ cmd_scan :: proc(cmd: ^Command) {
search_dirs := search_paths(db.cfg) search_dirs := search_paths(db.cfg)
if len(search_dirs) == 0 { if len(search_dirs) == 0 {
fmt.println("No search paths configured. Please run `envr init -f` or edit your config.") fmt.wprintln(
cmd.err,
"No search paths configured. Please run `envr init -f` or edit your config.",
flush = false,
)
return return
} }
@@ -31,7 +37,7 @@ cmd_scan :: proc(cmd: ^Command) {
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)
if !scan_ok { if !scan_ok {
fmt.printf("Error scanning %s\n", dir) fmt.wprintf(cmd.err, "Error scanning %s\n", dir, flush = false)
continue continue
} }
for f in found { for f in found {
@@ -47,24 +53,29 @@ cmd_scan :: proc(cmd: ^Command) {
files := find_unbacked(all_files[:], db_files[:]) files := find_unbacked(all_files[:], db_files[:])
if len(files) == 0 { if len(files) == 0 {
fmt.println("No .env files found to add.") fmt.wprintln(cmd.out, "No .env files found to add.", flush = false)
return return
} }
if !terminal.is_terminal(os.stdout) { if !terminal.is_terminal(os.stdout) {
output, marshal_err := json.marshal(files[:]) output, marshal_err := json.marshal(files[:])
if marshal_err != nil { if marshal_err != nil {
fmt.printf("Error marshaling files to JSON: %v\n", marshal_err) fmt.wprintf(
cmd.err,
"Error marshaling files to JSON: %v\n",
marshal_err,
flush = false,
)
return return
} }
fmt.println(string(output)) fmt.wprintln(cmd.out, string(output), flush = false)
return return
} }
selected, result := multi_select("Select .env files to backup:", files[:]) selected, result := multi_select("Select .env files to backup:", files[:])
defer delete(selected) defer delete(selected)
if result == .Cancel { if result == .Cancel {
fmt.println("\x1b[2mCancelled.\x1b[0m") fmt.wprintln(cmd.out, "\x1b[2mCancelled.\x1b[0m", flush = false)
return return
} }
@@ -75,20 +86,25 @@ cmd_scan :: proc(cmd: ^Command) {
} }
env_file, ok := new_env_file(files[i]) env_file, ok := new_env_file(files[i])
if !ok { if !ok {
fmt.printf("Error reading %s\n", files[i]) fmt.wprintf(cmd.err, "Error reading %s\n", files[i], flush = false)
continue continue
} }
if !db_insert(&db, env_file) { if !db_insert(&db, env_file) {
fmt.printf("Error adding %s\n", files[i]) fmt.wprintf(cmd.err, "Error adding %s\n", files[i], flush = false)
continue continue
} }
added_count += 1 added_count += 1
} }
if added_count > 0 { if added_count > 0 {
fmt.printf("\x1b[1;32mSuccessfully added %d file(s) to backup.\x1b[0m\n", added_count) fmt.wprintf(
cmd.out,
"\x1b[1;32mSuccessfully added %d file(s) to backup.\x1b[0m\n",
added_count,
flush = false,
)
} else { } else {
fmt.println("\x1b[2mNo files were added.\x1b[0m") fmt.wprintln(cmd.out, "\x1b[2mNo files were added.\x1b[0m", flush = false)
} }
} }

View File

@@ -2,7 +2,6 @@ package main
import "core:encoding/json" import "core:encoding/json"
import "core:fmt" import "core:fmt"
import "core:io"
import "core:os" import "core:os"
import "core:strings" import "core:strings"
import "core:terminal" import "core:terminal"
@@ -84,15 +83,14 @@ cmd_sync :: proc(cmd: ^Command) {
append(&table_rows, row_slice) append(&table_rows, row_slice)
} }
w := io.to_writer(os.to_writer(os.stdout)) render_table(cmd.out, headers, table_rows[:])
render_table(w, headers, table_rows[:])
} else { } else {
data, marshal_err := json.marshal(results[:]) data, marshal_err := json.marshal(results[:])
if marshal_err != nil { if marshal_err != nil {
fmt.printf("Error marshaling JSON: %v\n", marshal_err) fmt.wprintf(cmd.err, "Error marshaling JSON: %v\n", marshal_err, flush = false)
return return
} }
fmt.println(string(data)) fmt.wprintln(cmd.out, string(data), flush = false)
} }
} }

View File

@@ -5,6 +5,6 @@ import "core:fmt"
VERSION :: #load("version.txt", string) VERSION :: #load("version.txt", string)
cmd_version :: proc(cmd: ^Command) { cmd_version :: proc(cmd: ^Command) {
fmt.println(VERSION) fmt.wprintln(cmd.out, VERSION, flush = false)
} }

View File

@@ -23,8 +23,8 @@ Config :: struct {
config_path: string `json:"-"`, config_path: string `json:"-"`,
} }
default_config_path :: proc(home: string) -> string { default_config_path :: proc(home: string, allocator := context.allocator) -> string {
path, err := filepath.join([]string{home, ".envr", "config.json"}) path, err := filepath.join([]string{home, ".envr", "config.json"}, allocator)
if err != nil { if err != nil {
panic("Ran out of memory when building config path") panic("Ran out of memory when building config path")
} }
@@ -37,6 +37,7 @@ load_config :: proc(config_path: string) -> (Config, bool) {
fmt.println("No config file found. Please run `envr init` to generate one.") fmt.println("No config file found. Please run `envr init` to generate one.")
return Config{}, false return Config{}, false
} }
defer delete(data)
cfg: Config cfg: Config
// TODO: use json 5 // TODO: use json 5
@@ -50,9 +51,23 @@ load_config :: proc(config_path: string) -> (Config, bool) {
return cfg, true return cfg, true
} }
delete_config :: proc(cfg: Config) { delete_config :: proc(cfg: ^Config) {
for key in cfg.Keys {
delete(key.Private)
delete(key.Public)
}
delete(cfg.Keys) delete(cfg.Keys)
delete(cfg.ScanConfig.Matcher)
for exclude in cfg.ScanConfig.Exclude {
delete(exclude)
}
delete(cfg.ScanConfig.Exclude) delete(cfg.ScanConfig.Exclude)
for include in cfg.ScanConfig.Include {
delete(include)
}
delete(cfg.ScanConfig.Include) delete(cfg.ScanConfig.Include)
} }
@@ -60,7 +75,7 @@ envr_dir :: proc(config_path: string) -> string {
return filepath.dir(config_path) return filepath.dir(config_path)
} }
data_encrypted_path :: proc(config_path: string) -> string { data_path :: proc(config_path: string) -> string {
path, _ := filepath.join([]string{envr_dir(config_path), "data.envr"}) path, _ := filepath.join([]string{envr_dir(config_path), "data.envr"})
return path return path
} }
@@ -111,6 +126,7 @@ find_ssh_private_keys :: proc() -> (keys: [dynamic]string, ok: bool) {
return return
} }
// Caller is responsible for calling delete_config()
new_config :: proc( new_config :: proc(
private_key_paths: []string, private_key_paths: []string,
cfg_path: string = "~/.envr/config.json", cfg_path: string = "~/.envr/config.json",
@@ -118,21 +134,22 @@ new_config :: proc(
keys := make([dynamic]SshKeyPair, 0, len(private_key_paths)) keys := make([dynamic]SshKeyPair, 0, len(private_key_paths))
for priv in private_key_paths { for priv in private_key_paths {
// TODO: Is this bad? // TODO: Is this bad?
pub, _ := strings.concatenate([]string{priv, ".pub"}, context.temp_allocator) priv_key := strings.clone(priv)
append(&keys, SshKeyPair{Private = priv, Public = pub}) pub, _ := strings.concatenate([]string{priv_key, ".pub"})
append(&keys, SshKeyPair{Private = priv_key, Public = pub})
} }
exclude := make([dynamic]string, 0, 4) exclude := make([dynamic]string, 0, 4)
append(&exclude, "*\\.envrc") append(&exclude, strings.clone("*\\.envrc"))
append(&exclude, "\\.local/") append(&exclude, strings.clone("\\.local/"))
append(&exclude, "node_modules") append(&exclude, strings.clone("node_modules"))
append(&exclude, "vendor") append(&exclude, strings.clone("vendor"))
include := make([dynamic]string, 0, 1) include := make([dynamic]string, 0, 1)
append(&include, "~") append(&include, strings.clone("~"))
scan_cfg := ScanConfig { scan_cfg := ScanConfig {
Matcher = "\\.env", Matcher = strings.clone("\\.env"),
Exclude = exclude, Exclude = exclude,
Include = include, Include = include,
} }
@@ -167,6 +184,7 @@ save_config :: proc(cfg: Config, force: bool = false) -> bool {
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 {
@@ -178,15 +196,18 @@ save_config :: proc(cfg: Config, force: bool = false) -> bool {
} }
search_paths :: proc(cfg: Config) -> (paths: [dynamic]string) { search_paths :: proc(cfg: Config) -> (paths: [dynamic]string) {
home, _ := os.user_home_dir(context.allocator) // TODO: Is this okay?
// TODO: handle error
home, _ := os.user_home_dir(context.temp_allocator)
for include in cfg.ScanConfig.Include { for include in cfg.ScanConfig.Include {
// TODO: Do we need to manually expand ~/ in odin?
expanded, _ := strings.replace(include, "~", home, 1) expanded, _ := strings.replace(include, "~", home, 1)
cloned, _ := strings.clone(expanded) if filepath.is_abs(expanded) {
if filepath.is_abs(cloned) { append(&paths, expanded)
append(&paths, cloned)
} else { } else {
resolved, err := filepath.abs(cloned) defer delete(expanded)
resolved, err := filepath.abs(expanded)
if err == nil { if err == nil {
append(&paths, resolved) append(&paths, resolved)
} }

View File

@@ -13,7 +13,7 @@ home_mutex: sync.Mutex
test_new_config_single_key :: proc(t: ^testing.T) { test_new_config_single_key :: proc(t: ^testing.T) {
paths := []string{"/home/user/.ssh/id_ed25519"} paths := []string{"/home/user/.ssh/id_ed25519"}
cfg := new_config(paths) cfg := new_config(paths)
defer delete_config(cfg) defer delete_config(&cfg)
testing.expect(t, len(cfg.Keys) == 1, "should have 1 key") testing.expect(t, len(cfg.Keys) == 1, "should have 1 key")
testing.expect(t, cfg.Keys[0].Private == "/home/user/.ssh/id_ed25519", "Private path mismatch") testing.expect(t, cfg.Keys[0].Private == "/home/user/.ssh/id_ed25519", "Private path mismatch")
@@ -28,7 +28,7 @@ test_new_config_single_key :: proc(t: ^testing.T) {
test_new_config_multiple_keys :: proc(t: ^testing.T) { test_new_config_multiple_keys :: proc(t: ^testing.T) {
paths := []string{"/home/user/.ssh/id_ed25519", "/home/user/.ssh/id_rsa"} paths := []string{"/home/user/.ssh/id_ed25519", "/home/user/.ssh/id_rsa"}
cfg := new_config(paths) cfg := new_config(paths)
defer delete_config(cfg) defer delete_config(&cfg)
testing.expect(t, len(cfg.Keys) == 2, "should have 2 keys") testing.expect(t, len(cfg.Keys) == 2, "should have 2 keys")
testing.expect(t, cfg.Keys[0].Private == "/home/user/.ssh/id_ed25519") testing.expect(t, cfg.Keys[0].Private == "/home/user/.ssh/id_ed25519")
@@ -39,7 +39,7 @@ test_new_config_multiple_keys :: proc(t: ^testing.T) {
test_new_config_empty_keys :: proc(t: ^testing.T) { test_new_config_empty_keys :: proc(t: ^testing.T) {
paths: []string paths: []string
cfg := new_config(paths) cfg := new_config(paths)
defer delete_config(cfg) defer delete_config(&cfg)
testing.expect(t, len(cfg.Keys) == 0, "should have 0 keys") testing.expect(t, len(cfg.Keys) == 0, "should have 0 keys")
} }
@@ -48,7 +48,7 @@ test_new_config_empty_keys :: proc(t: ^testing.T) {
test_new_config_scan_defaults :: proc(t: ^testing.T) { test_new_config_scan_defaults :: proc(t: ^testing.T) {
paths := []string{"/home/user/.ssh/id_ed25519"} paths := []string{"/home/user/.ssh/id_ed25519"}
cfg := new_config(paths) cfg := new_config(paths)
defer delete_config(cfg) defer delete_config(&cfg)
testing.expect(t, cfg.ScanConfig.Matcher == "\\.env", "matcher should be \\.env") testing.expect(t, cfg.ScanConfig.Matcher == "\\.env", "matcher should be \\.env")
testing.expect(t, len(cfg.ScanConfig.Exclude) == 4, "should have 4 exclude patterns") testing.expect(t, len(cfg.ScanConfig.Exclude) == 4, "should have 4 exclude patterns")
@@ -60,7 +60,7 @@ test_new_config_scan_defaults :: proc(t: ^testing.T) {
test_new_config_exclude_patterns :: proc(t: ^testing.T) { test_new_config_exclude_patterns :: proc(t: ^testing.T) {
paths := []string{"/home/user/.ssh/id_ed25519"} paths := []string{"/home/user/.ssh/id_ed25519"}
cfg := new_config(paths) cfg := new_config(paths)
defer delete_config(cfg) defer delete_config(&cfg)
expected := []string{"*\\.envrc", "\\.local/", "node_modules", "vendor"} expected := []string{"*\\.envrc", "\\.local/", "node_modules", "vendor"}
for i in 0 ..< len(expected) { for i in 0 ..< len(expected) {
@@ -78,14 +78,14 @@ test_save_load_config_roundtrip :: proc(t: ^testing.T) {
testing.expect(t, err == nil, "cfgPath should build successfully") testing.expect(t, err == nil, "cfgPath should build successfully")
cfg := new_config([]string{"/home/user/.ssh/id_ed25519"}, cfgPath) cfg := new_config([]string{"/home/user/.ssh/id_ed25519"}, cfgPath)
defer delete_config(cfg) defer delete_config(&cfg)
testing.expect(t, save_config(cfg, force = true), "save should succeed") testing.expect(t, save_config(cfg, force = true), "save should succeed")
loaded, ok := load_config(cfg.config_path) loaded, ok := load_config(cfg.config_path)
testing.expect(t, ok, "load should succeed") testing.expect(t, ok, "load should succeed")
if !ok do return if !ok do return
defer delete_config(loaded) defer delete_config(&loaded)
testing.expect(t, len(loaded.Keys) == 1, "should have 1 key") testing.expect(t, len(loaded.Keys) == 1, "should have 1 key")
testing.expect(t, loaded.Keys[0].Private == "/home/user/.ssh/id_ed25519") testing.expect(t, loaded.Keys[0].Private == "/home/user/.ssh/id_ed25519")
@@ -112,11 +112,11 @@ test_save_config_no_clobber :: proc(t: ^testing.T) {
testing.expect(t, err == nil, "cfgPath should build successfully") testing.expect(t, err == nil, "cfgPath should build successfully")
cfg := new_config([]string{"/home/user/.ssh/key1"}, cfgPath) cfg := new_config([]string{"/home/user/.ssh/key1"}, cfgPath)
defer delete_config(cfg) defer delete_config(&cfg)
testing.expect(t, save_config(cfg, force = true), "first save should succeed") testing.expect(t, save_config(cfg, force = true), "first save should succeed")
cfg2 := new_config([]string{"/home/user/.ssh/key2"}, cfgPath) cfg2 := new_config([]string{"/home/user/.ssh/key2"}, cfgPath)
defer delete_config(cfg2) defer delete_config(&cfg2)
testing.expect(t, !save_config(cfg2), "second save without force should fail") testing.expect(t, !save_config(cfg2), "second save without force should fail")
} }
@@ -130,17 +130,17 @@ test_save_config_force_overwrites :: proc(t: ^testing.T) {
testing.expect(t, err == nil, "cfgPath should build successfully") testing.expect(t, err == nil, "cfgPath should build successfully")
cfg := new_config([]string{"/home/user/.ssh/key1"}, cfgPath) cfg := new_config([]string{"/home/user/.ssh/key1"}, cfgPath)
defer delete_config(cfg) defer delete_config(&cfg)
testing.expect(t, save_config(cfg, force = true), "first save should succeed") testing.expect(t, save_config(cfg, force = true), "first save should succeed")
cfg2 := new_config([]string{"/home/user/.ssh/key2"}, cfgPath) cfg2 := new_config([]string{"/home/user/.ssh/key2"}, cfgPath)
defer delete_config(cfg2) defer delete_config(&cfg2)
testing.expect(t, save_config(cfg2, force = true), "force save should overwrite") testing.expect(t, save_config(cfg2, force = true), "force save should overwrite")
loaded, ok := load_config(cfgPath) loaded, ok := load_config(cfgPath)
testing.expect(t, ok, "load should succeed") testing.expect(t, ok, "load should succeed")
if !ok do return if !ok do return
defer delete_config(loaded) defer delete_config(&loaded)
testing.expect(t, len(loaded.Keys) == 1, "should have 1 key") testing.expect(t, len(loaded.Keys) == 1, "should have 1 key")
testing.expect( testing.expect(
@@ -163,8 +163,9 @@ test_envr_dir :: proc(t: ^testing.T) {
} }
@(test) @(test)
test_data_encrypted_path :: proc(t: ^testing.T) { test_data_path :: proc(t: ^testing.T) {
p := data_encrypted_path("/tmp/envr-fake-home-datapath/config.json") p := data_path("/tmp/envr-fake-home-datapath/config.json")
defer delete(p)
testing.expectf(t, strings.has_suffix(p, "data.envr"), "should end with data.envr, got %s", p) testing.expectf(t, strings.has_suffix(p, "data.envr"), "should end with data.envr, got %s", p)
testing.expectf(t, strings.contains(p, ".envr"), "should contain .envr dir, got %s", p) testing.expectf(t, strings.contains(p, ".envr"), "should contain .envr dir, got %s", p)
} }
@@ -191,6 +192,9 @@ test_search_paths_expands_tilde :: proc(t: ^testing.T) {
paths := search_paths(cfg) paths := search_paths(cfg)
defer delete(paths) 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 {

206
db.odin
View File

@@ -41,10 +41,21 @@ EnvFile :: struct {
contents: string, contents: string,
} }
delete_envfile :: proc(f: ^EnvFile) {
delete(f.Path)
for &remote in f.Remotes {
delete(remote)
}
delete(f.Remotes)
delete(f.Sha256)
delete(f.contents)
}
make_temp_path :: proc() -> string { make_temp_path :: proc() -> string {
ts := time.time_to_unix(time.now()) ts := time.time_to_unix(time.now())
b: strings.Builder b: strings.Builder
strings.builder_init(&b) strings.builder_init(&b)
defer strings.builder_destroy(&b)
fmt.sbprintf(&b, "/tmp/envr-%d-%d.db", os.get_pid(), ts) fmt.sbprintf(&b, "/tmp/envr-%d-%d.db", os.get_pid(), ts)
return strings.to_string(b) return strings.to_string(b)
} }
@@ -55,7 +66,7 @@ db_open :: proc(cfg_path: string) -> (Db, bool) {
return Db{}, false return Db{}, false
} }
data_path := data_encrypted_path(cfg.config_path) data_path := data_path(cfg.config_path)
_, stat_err := os.stat(data_path, context.allocator) _, stat_err := os.stat(data_path, context.allocator)
db: ^rawptr db: ^rawptr
@@ -84,32 +95,31 @@ db_open :: proc(cfg_path: string) -> (Db, bool) {
} }
db_close :: proc(d: ^Db) { db_close :: proc(d: ^Db) {
defer sqlite.db_close(d.db)
if d.changed { if d.changed {
tmp_path := make_temp_path() rc := sqlite.db_exec(d.db, "VACUUM", nil, nil, nil)
if rc != sqlite.OK {
if !db_vacuum_to_file(d.db, tmp_path) { fmt.printf("Error vacuuming database: %s\n", sqlite.db_errmsg(d.db))
os.remove(tmp_path)
sqlite.db_close(d.db)
return return
} }
sqlite_data, read_err := os.read_entire_file_from_path(tmp_path, context.allocator) sz: i64
os.remove(tmp_path) data := sqlite.serialize(d.db, "main", &sz, 0)
if read_err != nil { if data == nil {
fmt.printf("Error reading vacuumed database: %v\n", read_err) fmt.println("Error: failed to serialize database")
sqlite.db_close(d.db)
return return
} }
defer sqlite.free(data)
sqlite_data := data[:sz]
encrypted, enc_ok := encrypt(sqlite_data, d.cfg.Keys[:]) encrypted, enc_ok := encrypt(sqlite_data, d.cfg.Keys[:])
delete(sqlite_data)
if !enc_ok { if !enc_ok {
fmt.println("Error: encryption failed") fmt.println("Error: encryption failed")
sqlite.db_close(d.db)
return return
} }
data_path := data_encrypted_path(d.cfg.config_path) data_path := data_path(d.cfg.config_path)
envr_d := envr_dir(d.cfg.config_path) envr_d := envr_dir(d.cfg.config_path)
os.mkdir_all(envr_d) os.mkdir_all(envr_d)
@@ -117,15 +127,20 @@ db_close :: proc(d: ^Db) {
delete(encrypted) delete(encrypted)
if write_err != nil { if write_err != nil {
fmt.printf("Error writing encrypted database: %v\n", write_err) fmt.printf("Error writing encrypted database: %v\n", write_err)
sqlite.db_close(d.db)
return return
} }
d.changed = false d.changed = false
} }
sqlite.db_close(d.db)
} }
// Caller is responsible for calling:
// ```odin
// delete(results)
// for &result in results {
// delete(&result)
// }
// ```
db_list :: proc(d: ^Db, allocator := context.allocator) -> (results: [dynamic]EnvFile, ok: bool) { db_list :: proc(d: ^Db, allocator := context.allocator) -> (results: [dynamic]EnvFile, ok: bool) {
stmt: ^rawptr stmt: ^rawptr
rc := sqlite.prepare_v2( rc := sqlite.prepare_v2(
@@ -139,6 +154,7 @@ db_list :: proc(d: ^Db, allocator := context.allocator) -> (results: [dynamic]En
fmt.printf("Error preparing query: %s\n", sqlite.db_errmsg(d.db)) fmt.printf("Error preparing query: %s\n", sqlite.db_errmsg(d.db))
return return
} }
defer sqlite.finalize(stmt)
for { for {
rc = sqlite.step(stmt) rc = sqlite.step(stmt)
@@ -147,14 +163,13 @@ db_list :: proc(d: ^Db, allocator := context.allocator) -> (results: [dynamic]En
} }
if rc != sqlite.ROW { 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))
sqlite.finalize(stmt)
return return
} }
remotes_json := string(sqlite.column_text(stmt, 1)) remotes_json := string(sqlite.column_text(stmt, 1))
remotes := make([dynamic]string, strings.count(remotes_json, ",") + 1, allocator) remotes: [dynamic]string = ---
if len(remotes_json) > 0 { if len(remotes_json) > 0 {
json.unmarshal_string(remotes_json, &remotes) json.unmarshal_string(remotes_json, &remotes, allocator = allocator)
} }
path := clone_cstring(sqlite.column_text(stmt, 0), allocator) path := clone_cstring(sqlite.column_text(stmt, 0), allocator)
@@ -170,26 +185,16 @@ db_list :: proc(d: ^Db, allocator := context.allocator) -> (results: [dynamic]En
) )
} }
sqlite.finalize(stmt)
ok = true ok = true
return return
} }
db_vacuum_to_file :: proc(db: ^rawptr, path: string) -> bool {
b: strings.Builder
strings.builder_init(&b)
fmt.sbprintf(&b, "VACUUM INTO '%s'", path)
rc := sqlite.db_exec(db, to_cstring(&b), nil, nil, nil)
if rc != sqlite.OK {
fmt.printf("Error vacuuming database: %s\n", sqlite.db_errmsg(db))
return false
}
return true
}
db_restore_from_encrypted :: proc(db: ^rawptr, cfg: Config) -> bool { db_restore_from_encrypted :: proc(db: ^rawptr, cfg: Config) -> bool {
data_path := data_encrypted_path(cfg.config_path) encrypted_data, read_err := os.read_entire_file_from_path(
encrypted_data, read_err := os.read_entire_file_from_path(data_path, context.temp_allocator) data_path(cfg.config_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
@@ -202,56 +207,39 @@ db_restore_from_encrypted :: proc(db: ^rawptr, cfg: Config) -> bool {
} }
defer delete(plaintext) defer delete(plaintext)
tmp_path := make_temp_path() n := i64(len(plaintext))
write_err := os.write_entire_file(tmp_path, plaintext) buf := sqlite.malloc64(n)
if write_err != nil { if buf == nil {
fmt.printf("Error writing temp database: %v\n", write_err) fmt.println("Error: failed to allocate buffer for deserialization")
return false return false
} }
defer os.remove(tmp_path) copy(buf[:len(plaintext)], plaintext)
if !db_attach_and_copy(db, tmp_path) { rc := sqlite.deserialize(
return false db,
} "main",
buf,
return true n,
} n,
sqlite.DESERIALIZE_FREEONCLOSE | sqlite.DESERIALIZE_RESIZEABLE,
db_attach_and_copy :: proc(mem_db: ^rawptr, src_path: string) -> bool {
b: strings.Builder
strings.builder_init(&b)
fmt.sbprintf(&b, "ATTACH DATABASE '%s' AS source", src_path)
attach_sql := strings.to_string(b)
rc := sqlite.db_exec(mem_db, to_cstring(attach_sql), nil, nil, nil)
if rc != sqlite.OK {
fmt.printf("Error attaching database: %s\n", sqlite.db_errmsg(mem_db))
return false
}
rc = sqlite.db_exec(
mem_db,
"INSERT INTO main.envr_env_files SELECT * FROM source.envr_env_files",
nil,
nil,
nil,
) )
if rc != sqlite.OK { if rc != sqlite.OK {
fmt.printf("Error copying data: %s\n", sqlite.db_errmsg(mem_db)) sqlite.free(buf)
sqlite.db_exec(mem_db, "DETACH DATABASE source", nil, nil, nil) fmt.printf("Error deserializing database: %s\n", sqlite.db_errmsg(db))
return false return false
} }
sqlite.db_exec(mem_db, "DETACH DATABASE source", nil, nil, nil)
return true return true
} }
get_git_remotes :: proc(dir: string) -> [dynamic]string { get_git_remotes :: proc(dir: string) -> [dynamic]string {
remotes: [dynamic]string remotes: [dynamic]string
remote_set: map[string]bool remote_set: map[string]bool
b: strings.Builder b: strings.Builder
strings.builder_init(&b) strings.builder_init(&b)
defer strings.builder_destroy(&b)
fmt.sbprintf(&b, "%s-git-remotes", make_temp_path()) fmt.sbprintf(&b, "%s-git-remotes", make_temp_path())
tmp_path := strings.to_string(b) tmp_path := strings.to_string(b)
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)
@@ -281,6 +269,7 @@ get_git_remotes :: proc(dir: string) -> [dynamic]string {
} }
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.allocator)
defer delete(data)
os.remove(tmp_path) os.remove(tmp_path)
if read_err != nil { if read_err != nil {
return remotes return remotes
@@ -313,27 +302,24 @@ new_env_file :: proc(path: string) -> (EnvFile, bool) {
fmt.printf("Error getting absolute path: %v\n", abs_err) fmt.printf("Error getting absolute path: %v\n", abs_err)
return EnvFile{}, false return EnvFile{}, false
} }
cloned_path, err := strings.clone(abs_path)
if err != nil {
panic("Ran out of memory")
}
dir := filepath.dir(cloned_path) dir := filepath.dir(abs_path)
remotes := get_git_remotes(dir) remotes := get_git_remotes(dir)
data, read_err := os.read_entire_file_from_path(cloned_path, context.allocator) data, read_err := os.read_entire_file_from_path(abs_path, context.allocator)
defer delete(data)
if read_err != nil { if read_err != nil {
fmt.printf("Error reading file %s: %v\n", cloned_path, read_err) fmt.printf("Error reading file %s: %v\n", abs_path, read_err)
return EnvFile{}, false return EnvFile{}, false
} }
digest := hash.hash_bytes(hash.Algorithm.SHA256, data) 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)
return EnvFile { return EnvFile {
Path = cloned_path, Path = abs_path,
Dir = dir, Dir = dir,
Remotes = remotes, Remotes = remotes,
Sha256 = string(hex_bytes), Sha256 = string(hex_bytes),
@@ -348,6 +334,7 @@ db_insert :: proc(d: ^Db, file: EnvFile) -> bool {
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 " +
@@ -364,18 +351,34 @@ db_insert :: proc(d: ^Db, file: EnvFile) -> bool {
cpath := to_cstring(file.Path) cpath := to_cstring(file.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)
if rc != sqlite.OK {
fmt.printf("Error binding path: %s\n", sqlite.db_errmsg(d.db))
return false
}
cremotes := to_cstring(string(remotes_json)) cremotes := to_cstring(string(remotes_json))
defer delete(cremotes) defer delete(cremotes)
rc = sqlite.bind_text(stmt, 2, cremotes, -1, nil) rc = sqlite.bind_text(stmt, 2, cremotes, -1, nil)
if rc != sqlite.OK {
fmt.printf("Error binding remotes: %s\n", sqlite.db_errmsg(d.db))
return false
}
csha := to_cstring(file.Sha256) csha := to_cstring(file.Sha256)
defer delete(csha) defer delete(csha)
rc = sqlite.bind_text(stmt, 3, csha, -1, nil) rc = sqlite.bind_text(stmt, 3, csha, -1, nil)
if rc != sqlite.OK {
fmt.printf("Error binding sha256: %s\n", sqlite.db_errmsg(d.db))
return false
}
ccontents := to_cstring(file.contents) ccontents := to_cstring(file.contents)
defer delete(ccontents) defer delete(ccontents)
rc = sqlite.bind_text(stmt, 4, ccontents, -1, nil) rc = sqlite.bind_text(stmt, 4, ccontents, -1, nil)
if rc != sqlite.OK {
fmt.printf("Error binding contents: %s\n", sqlite.db_errmsg(d.db))
return false
}
rc = sqlite.step(stmt) rc = sqlite.step(stmt)
if rc != sqlite.DONE { if rc != sqlite.DONE {
@@ -400,6 +403,10 @@ db_fetch :: proc(d: ^Db, path: string, allocator := context.allocator) -> (EnvFi
cpath := to_cstring(path, allocator) cpath := to_cstring(path, allocator)
defer delete(cpath, 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 {
fmt.printf("Error binding path: %s\n", sqlite.db_errmsg(d.db))
return EnvFile{}, false
}
rc = sqlite.step(stmt) rc = sqlite.step(stmt)
if rc == sqlite.DONE { if rc == sqlite.DONE {
fmt.printf("No file found with path: %s\n", path) fmt.printf("No file found with path: %s\n", path)
@@ -411,7 +418,7 @@ db_fetch :: proc(d: ^Db, path: string, allocator := context.allocator) -> (EnvFi
} }
remotes_json := string(sqlite.column_text(stmt, 1)) remotes_json := string(sqlite.column_text(stmt, 1))
remotes := make([dynamic]string, strings.count(remotes_json, ",") + 1, allocator) remotes: [dynamic]string = ---
if len(remotes_json) > 0 { if len(remotes_json) > 0 {
json.unmarshal_string(remotes_json, &remotes, allocator = allocator) json.unmarshal_string(remotes_json, &remotes, allocator = allocator)
} }
@@ -441,6 +448,10 @@ db_delete :: proc(d: ^Db, path: string) -> bool {
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)
if rc != sqlite.OK {
fmt.printf("Error binding path: %s\n", sqlite.db_errmsg(d.db))
return false
}
rc = sqlite.step(stmt) rc = sqlite.step(stmt)
if rc != sqlite.DONE { if rc != sqlite.DONE {
fmt.printf("Error deleting: %s\n", sqlite.db_errmsg(d.db)) fmt.printf("Error deleting: %s\n", sqlite.db_errmsg(d.db))
@@ -474,6 +485,7 @@ 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 {
fmt.printf("Failed to convert string to cstring: %v\n", err) fmt.printf("Failed to convert string to cstring: %v\n", err)
delete(str)
panic("Allocation Exception") panic("Allocation Exception")
} }
@@ -526,24 +538,11 @@ find_moved_dirs :: proc(d: ^Db, f: ^EnvFile) -> ([dynamic]string, bool) {
return moved, true return moved, true
} }
env_file_backup :: proc(f: ^EnvFile) -> bool { db_sync :: proc(d: ^Db, f: ^EnvFile) -> (SyncFlag, string) {
data, read_err := os.read_entire_file_from_path(f.Path, context.allocator) return env_file_sync(f, .TrustFilesystem, d)
if read_err != nil {
fmt.printf("Error reading file %s: %v\n", f.Path, read_err)
return false
}
f.contents = string(data)
digest := hash.hash_bytes(hash.Algorithm.SHA256, data)
hex_bytes, alloc_err := hex.encode(digest)
if alloc_err != nil {
fmt.printf("Error generating hash for file %s: %v\n", f.Path, alloc_err)
return false
}
f.Sha256 = string(hex_bytes)
return true
} }
// If SyncFlag is .BackedUp, Caller is responsible for calling delete on f.contents and f.Sha256
env_file_sync :: proc(f: ^EnvFile, dir: SyncDirection, d: ^Db) -> (SyncFlag, string) { env_file_sync :: proc(f: ^EnvFile, dir: SyncDirection, d: ^Db) -> (SyncFlag, string) {
result: SyncFlag = {} result: SyncFlag = {}
@@ -615,7 +614,24 @@ env_file_sync :: proc(f: ^EnvFile, dir: SyncDirection, d: ^Db) -> (SyncFlag, str
return result, "" return result, ""
} }
db_sync :: proc(d: ^Db, f: ^EnvFile) -> (SyncFlag, string) { // Loads the contents of the the file at f.Path into f.contents
return env_file_sync(f, .TrustFilesystem, d) //
// Caller is responsible for calling delete on f.contents and f.Sha256
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)
digest := hash.hash_bytes(hash.Algorithm.SHA256, data, context.temp_allocator)
hex_bytes, alloc_err := hex.encode(digest)
if alloc_err != nil {
fmt.printf("Error generating hash for file %s: %v\n", f.Path, alloc_err)
return false
}
f.Sha256 = string(hex_bytes)
return true
} }

View File

@@ -136,7 +136,7 @@ test_encrypt_write_read_decrypt :: proc(t: ^testing.T) {
} }
@(test) @(test)
test_decrypt_then_attach_sqlite :: proc(t: ^testing.T) { test_decrypt_then_deserialize_sqlite :: proc(t: ^testing.T) {
cfg := fixture_config() cfg := fixture_config()
defer { defer {
delete(cfg.Keys) delete(cfg.Keys)
@@ -164,14 +164,6 @@ test_decrypt_then_attach_sqlite :: proc(t: ^testing.T) {
} }
defer delete(plaintext) defer delete(plaintext)
tmp_db_path := fmt.tprintf("/tmp/envr-test-attach-%d.db", os.get_pid())
write_err := os.write_entire_file(tmp_db_path, plaintext)
testing.expectf(t, write_err == nil, "failed to write temp db: %v", write_err)
if write_err != nil {
return
}
defer os.remove(tmp_db_path)
mem_db: ^rawptr mem_db: ^rawptr
rc := sqlite.db_open(":memory:", &mem_db) rc := sqlite.db_open(":memory:", &mem_db)
testing.expectf(t, rc == sqlite.OK, "failed to open in-memory db") testing.expectf(t, rc == sqlite.OK, "failed to open in-memory db")
@@ -180,16 +172,29 @@ test_decrypt_then_attach_sqlite :: proc(t: ^testing.T) {
} }
defer sqlite.db_close(mem_db) defer sqlite.db_close(mem_db)
create_sql := "CREATE TABLE IF NOT EXISTS envr_env_files (path TEXT PRIMARY KEY NOT NULL, remotes TEXT, sha256 TEXT NOT NULL, contents TEXT NOT NULL)" n := i64(len(plaintext))
rc = sqlite.db_exec(mem_db, string_to_cstring(create_sql), nil, nil, nil) buf := sqlite.malloc64(n)
testing.expect(t, rc == sqlite.OK, "failed to create table") testing.expect(t, buf != nil, "malloc64 should succeed")
if buf == nil do return
copy(buf[:len(plaintext)], plaintext)
attach_ok := db_attach_and_copy(mem_db, tmp_db_path) rc = sqlite.deserialize(
testing.expect(t, attach_ok, "failed to attach and copy") mem_db,
"main",
buf,
n,
n,
sqlite.DESERIALIZE_FREEONCLOSE | sqlite.DESERIALIZE_RESIZEABLE,
)
testing.expect(t, rc == sqlite.OK, "deserialize should succeed")
if rc != sqlite.OK {
sqlite.free(buf)
return
}
sql := "SELECT path FROM envr_env_files" sql: cstring = "SELECT path FROM envr_env_files"
stmt: ^rawptr stmt: ^rawptr
rc = sqlite.prepare_v2(mem_db, string_to_cstring(sql), -1, &stmt, nil) rc = sqlite.prepare_v2(mem_db, sql, -1, &stmt, nil)
testing.expect(t, rc == sqlite.OK, "prepare failed") testing.expect(t, rc == sqlite.OK, "prepare failed")
if rc != sqlite.OK { if rc != sqlite.OK {
return return
@@ -207,9 +212,7 @@ test_decrypt_then_attach_sqlite :: proc(t: ^testing.T) {
@(test) @(test)
test_full_db_cycle :: proc(t: ^testing.T) { test_full_db_cycle :: proc(t: ^testing.T) {
cfg := fixture_config() cfg := fixture_config()
defer { defer delete(cfg.Keys)
delete(cfg.Keys)
}
db_path := fixture_db_path() db_path := fixture_db_path()
original_data, read_err := os.read_entire_file_from_path(db_path, context.allocator) original_data, read_err := os.read_entire_file_from_path(db_path, context.allocator)
@@ -230,6 +233,7 @@ test_full_db_cycle :: proc(t: ^testing.T) {
os.mkdir_all(envr_dir_path) os.mkdir_all(envr_dir_path)
data_path, _ := filepath.join([]string{envr_dir_path, "data.envr"}) data_path, _ := filepath.join([]string{envr_dir_path, "data.envr"})
defer delete(data_path)
write_err := os.write_entire_file(data_path, encrypted) write_err := os.write_entire_file(data_path, encrypted)
testing.expectf(t, write_err == nil, "failed to write data.envr: %v", write_err) testing.expectf(t, write_err == nil, "failed to write data.envr: %v", write_err)
if write_err != nil { if write_err != nil {

View File

@@ -15,8 +15,8 @@ make_test_db :: proc() -> (Db, bool) {
return Db{}, false return Db{}, false
} }
create_sql := "CREATE TABLE IF NOT EXISTS envr_env_files (path TEXT PRIMARY KEY NOT NULL, remotes TEXT, sha256 TEXT NOT NULL, contents TEXT NOT NULL)" create_sql: cstring = "CREATE TABLE IF NOT EXISTS envr_env_files (path TEXT PRIMARY KEY NOT NULL, remotes TEXT, sha256 TEXT NOT NULL, contents TEXT NOT NULL)"
rc = sqlite.db_exec(db, string_to_cstring(create_sql), nil, nil, nil) rc = sqlite.db_exec(db, create_sql, nil, nil, nil)
if rc != sqlite.OK { if rc != sqlite.OK {
sqlite.db_close(db) sqlite.db_close(db)
return Db{}, false return Db{}, false
@@ -56,9 +56,9 @@ test_db_insert_and_fetch :: proc(t: ^testing.T) {
testing.expect(t, db_insert(&d, f), "insert should succeed") testing.expect(t, db_insert(&d, f), "insert should succeed")
fetched, fetch_ok := db_fetch(&d, "/project/.env") fetched, fetch_ok := db_fetch(&d, "/project/.env")
defer delete_envfile(&fetched)
testing.expect(t, fetch_ok, "fetch should succeed") testing.expect(t, fetch_ok, "fetch should succeed")
if !fetch_ok do return if !fetch_ok do return
defer delete(fetched.Remotes)
testing.expect_value(t, fetched.Path, path) testing.expect_value(t, fetched.Path, path)
testing.expect_value(t, fetched.Sha256, sha) testing.expect_value(t, fetched.Sha256, sha)
@@ -97,16 +97,19 @@ test_db_insert_or_replace :: proc(t: ^testing.T) {
testing.expect(t, list_ok, "list should succeed") testing.expect(t, list_ok, "list should succeed")
if !list_ok do return if !list_ok do return
defer delete(results) defer delete(results)
for &result in results {
defer delete_envfile(&result)
}
testing.expect(t, len(results) == 1, "should have 1 row, not 2") testing.expect(t, len(results) == 1, "should have 1 row, not 2")
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 if !fetch_ok do return
defer delete(fetched.Remotes) defer delete_envfile(&fetched)
testing.expect(t, fetched.contents == "KEY=new", "contents should be updated") testing.expect_value(t, fetched.contents, "KEY=new")
testing.expect(t, fetched.Sha256 == "sha2", "sha should be updated") testing.expect_value(t, fetched.Sha256, "sha2")
} }
@(test) @(test)
@@ -144,11 +147,10 @@ test_db_list_multiple :: proc(t: ^testing.T) {
defer sqlite.db_close(d.db) defer sqlite.db_close(d.db)
f1 := make_test_env_file("/proj1/.env", "sha1", "A=1", []string{"git@github.com:a/repo.git"}) f1 := make_test_env_file("/proj1/.env", "sha1", "A=1", []string{"git@github.com:a/repo.git"})
f2 := make_test_env_file("/proj2/.env", "sha2", "B=2", []string{"git@github.com:b/repo.git"})
f3 := make_test_env_file("/proj3/.env", "sha3", "C=3")
defer delete(f1.Remotes) defer delete(f1.Remotes)
f2 := make_test_env_file("/proj2/.env", "sha2", "B=2", []string{"git@github.com:b/repo.git"})
defer delete(f2.Remotes) defer delete(f2.Remotes)
defer delete(f3.Remotes) f3 := make_test_env_file("/proj3/.env", "sha3", "C=3")
db_insert(&d, f1) db_insert(&d, f1)
db_insert(&d, f2) db_insert(&d, f2)
@@ -158,8 +160,13 @@ test_db_list_multiple :: proc(t: ^testing.T) {
testing.expect(t, list_ok, "list should succeed") testing.expect(t, list_ok, "list should succeed")
if !list_ok do return if !list_ok do return
defer delete(results) defer delete(results)
defer {
for &result in results {
delete_envfile(&result)
}
}
testing.expect(t, len(results) == 3, "should have 3 rows") testing.expect_value(t, len(results), 3)
} }
@(test) @(test)
@@ -208,7 +215,7 @@ test_db_delete_sets_changed :: proc(t: ^testing.T) {
} }
@(test) @(test)
test_db_vacuum_to_file :: proc(t: ^testing.T) { test_db_serialize :: proc(t: ^testing.T) {
d, ok := make_test_db() d, ok := make_test_db()
testing.expect(t, ok, "failed to create test db") testing.expect(t, ok, "failed to create test db")
if !ok do return if !ok do return
@@ -218,21 +225,13 @@ test_db_vacuum_to_file :: proc(t: ^testing.T) {
defer delete(f.Remotes) defer delete(f.Remotes)
db_insert(&d, f) db_insert(&d, f)
vacuum_path := fmt.tprintf("/tmp/envr-test-vacuum-%d.db", os.get_pid()) sz: i64
defer os.remove(vacuum_path) data := sqlite.serialize(d.db, "main", &sz, 0)
testing.expect(t, data != nil, "serialize should return non-nil")
if data == nil do return
defer sqlite.free(data)
testing.expect(t, db_vacuum_to_file(d.db, vacuum_path), "vacuum should succeed") testing.expect(t, sz > 0, "serialized size should be > 0")
_, stat_err := os.stat(vacuum_path, context.allocator)
testing.expect(t, stat_err == nil, "vacuumed file should exist")
if stat_err != nil do return
data, read_err := os.read_entire_file_from_path(vacuum_path, context.allocator)
testing.expect(t, read_err == nil, "should read vacuumed file")
if read_err != nil do return
defer delete(data)
testing.expect(t, len(data) > 0, "vacuumed file should be non-empty")
} }
@(test) @(test)
@@ -341,6 +340,8 @@ test_new_env_file :: proc(t: ^testing.T) {
testing.expect(t, ok, "new_env_file should succeed") testing.expect(t, ok, "new_env_file should succeed")
if !ok do return if !ok do return
defer delete(file.Remotes) defer delete(file.Remotes)
defer delete(file.Sha256)
defer delete(file.Path)
testing.expect(t, filepath.is_abs(file.Path), "path should be absolute") testing.expect(t, filepath.is_abs(file.Path), "path should be absolute")
testing.expect(t, strings.has_suffix(file.Path, "/.env"), "path should end with /.env") testing.expect(t, strings.has_suffix(file.Path, "/.env"), "path should end with /.env")
@@ -367,9 +368,11 @@ test_env_file_backup :: proc(t: ^testing.T) {
f := EnvFile { f := EnvFile {
Path = env_path, Path = env_path,
} }
defer delete(f.contents)
defer delete(f.Sha256)
testing.expect(t, env_file_backup(&f), "backup should succeed") testing.expect(t, env_file_backup(&f), "backup should succeed")
testing.expect(t, f.contents == "KEY=12345\n", "contents should be populated") testing.expect_value(t, f.contents, "KEY=12345\n")
testing.expect(t, len(f.Sha256) == 64, "sha256 should be 64 hex chars") testing.expect_value(t, len(f.Sha256), 64)
} }
@(test) @(test)
@@ -387,11 +390,11 @@ test_update_dir :: proc(t: ^testing.T) {
Dir = "/old/project", Dir = "/old/project",
Remotes = make([dynamic]string, 0), Remotes = make([dynamic]string, 0),
} }
defer delete(f.Remotes) defer delete_envfile(&f)
update_dir(&f, "/new/location") update_dir(&f, "/new/location")
testing.expect(t, f.Dir == "/new/location", "dir should be updated") testing.expect_value(t, f.Dir, "/new/location")
testing.expect(t, f.Path == "/new/location/.env", "path should be updated") testing.expect_value(t, f.Path, "/new/location/.env")
} }

View File

@@ -11,11 +11,12 @@
}; };
outputs = outputs =
inputs@{ flake-parts inputs@{
, nixpkgs flake-parts,
, nixpkgs-unstable nixpkgs,
, self nixpkgs-unstable,
, treefmt-nix self,
treefmt-nix,
}: }:
flake-parts.lib.mkFlake { inherit inputs; } { flake-parts.lib.mkFlake { inherit inputs; } {
imports = [ imports = [
@@ -29,7 +30,18 @@
]; ];
perSystem = perSystem =
{ pkgs, system, inputs', ... }: { {
pkgs,
system,
inputs',
...
}:
let
mysqlite = pkgs.sqlite.overrideAttrs (old: {
configureFlags = (old.configureFlags or [ ]) ++ [ "--enable-deserialize" ];
});
in
{
_module.args.pkgs = import nixpkgs { _module.args.pkgs = import nixpkgs {
inherit system; inherit system;
config.allowUnfree = true; config.allowUnfree = true;
@@ -64,7 +76,7 @@
buildInputs = [ buildInputs = [
pkgs.libsodium pkgs.libsodium
pkgs.sqlite mysqlite
]; ];
buildPhase = '' buildPhase = ''
@@ -87,7 +99,7 @@
nushell nushell
libsodium libsodium
sqlite mysqlite
unstable.odin unstable.odin
unstable.ols unstable.ols

View File

@@ -1,10 +1,14 @@
package main package main
import "core:bufio"
import "core:fmt" import "core:fmt"
import "core:os" import "core:os"
main :: proc() { main :: proc() {
cmd, ok := parse_args(os.args) defer free_all(context.temp_allocator)
cmd, ok := parse_args(os.args, os.to_writer(os.stdout), os.to_writer(os.stderr))
defer bufio.writer_flush(cmd.out_buf)
if !ok { if !ok {
return return
} }
@@ -35,10 +39,9 @@ main :: proc() {
case "nushell-completion": case "nushell-completion":
cmd_nushell_completion(&cmd) cmd_nushell_completion(&cmd)
case: case:
fmt.printf("Unknown command: %s\n", cmd.name) fmt.wprintf(cmd.err, "Unknown command: %s\n", cmd.name)
print_usage() write_usage(cmd.out)
os.exit(1) os.exit(1)
} }
} }

View File

@@ -1,44 +0,0 @@
package main
import "core:fmt"
import "core:os"
main :: proc() {
cmd, ok := parse_args()
if !ok {
return
}
switch cmd.name {
case "init":
cmd_init(&cmd)
case "version":
cmd_version(&cmd)
case "deps":
cmd_deps(&cmd)
case "list":
cmd_list(&cmd)
case "backup", "add":
cmd_backup(&cmd)
case "remove":
cmd_remove(&cmd)
case "restore":
cmd_restore(&cmd)
case "edit-config":
cmd_edit_config(&cmd)
case "check":
cmd_check(&cmd)
case "scan":
cmd_scan(&cmd)
case "sync":
cmd_sync(&cmd)
case "nushell-completion":
cmd_nushell_completion(&cmd)
case:
fmt.printf("Unknown command: %s\n", cmd.name)
print_usage()
os.exit(1)
}
}

View File

@@ -3,36 +3,9 @@ package main
import "core:fmt" import "core:fmt"
import "core:sys/posix" import "core:sys/posix"
Raw_State :: struct { MultiSelect_Result :: enum {
original: posix.termios, Confirm,
fd: posix.FD, Cancel,
}
enable_raw_mode :: proc(fd: posix.FD) -> (Raw_State, bool) {
state: Raw_State
state.fd = fd
if posix.tcgetattr(fd, &state.original) != .OK {
return state, false
}
attr: posix.termios = state.original
attr.c_lflag -= {.ICANON, .ECHO, .ISIG, .IEXTEN}
attr.c_iflag -= {.IXON, .ICRNL, .BRKINT, .INPCK, .ISTRIP}
attr.c_oflag -= {.OPOST}
attr.c_cflag += {.CS8}
attr.c_cc[.VMIN] = 1
attr.c_cc[.VTIME] = 0
if posix.tcsetattr(fd, .TCSAFLUSH, &attr) != .OK {
return state, false
}
return state, true
}
disable_raw_mode :: proc(state: ^Raw_State) {
posix.tcsetattr(state.fd, .TCSAFLUSH, &state.original)
} }
Key :: enum { Key :: enum {
@@ -44,71 +17,9 @@ Key :: enum {
Unknown, Unknown,
} }
read_key :: proc() -> Key { Raw_State :: struct {
buf: [3]u8 original: posix.termios,
fd: posix.FD,
n := posix.read(posix.STDIN_FILENO, &buf[0], 1)
if n <= 0 {
return .Unknown
}
switch buf[0] {
case ' ':
return .Space
case '\n', '\r':
return .Enter
case 0x03:
return .Escape
case 0x1b:
tv: posix.timeval
tv.tv_sec = 0
tv.tv_usec = posix.suseconds_t(100000)
set: posix.fd_set
posix.FD_ZERO(&set)
posix.FD_SET(posix.STDIN_FILENO, &set)
ready := posix.select(1, &set, nil, nil, &tv)
if ready <= 0 {
return .Escape
}
n2 := posix.read(posix.STDIN_FILENO, &buf[1], 1)
if n2 <= 0 || buf[1] != '[' {
return .Escape
}
posix.FD_ZERO(&set)
posix.FD_SET(posix.STDIN_FILENO, &set)
tv.tv_sec = 0
tv.tv_usec = posix.suseconds_t(100000)
ready = posix.select(1, &set, nil, nil, &tv)
if ready <= 0 {
return .Escape
}
n3 := posix.read(posix.STDIN_FILENO, &buf[2], 1)
if n3 <= 0 {
return .Escape
}
switch buf[2] {
case 'A':
return .Up
case 'B':
return .Down
case:
return .Escape
}
case:
return .Unknown
}
}
MultiSelect_Result :: enum {
Confirm,
Cancel,
} }
MAX_VISIBLE :: 7 MAX_VISIBLE :: 7
@@ -125,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
@@ -199,3 +110,92 @@ render_options :: proc(
return end - scroll_offset return end - scroll_offset
} }
enable_raw_mode :: proc(fd: posix.FD) -> (Raw_State, bool) {
state: Raw_State
state.fd = fd
if posix.tcgetattr(fd, &state.original) != .OK {
return state, false
}
attr: posix.termios = state.original
attr.c_lflag -= {.ICANON, .ECHO, .ISIG, .IEXTEN}
attr.c_iflag -= {.IXON, .ICRNL, .BRKINT, .INPCK, .ISTRIP}
attr.c_oflag -= {.OPOST}
attr.c_cflag += {.CS8}
attr.c_cc[.VMIN] = 1
attr.c_cc[.VTIME] = 0
if posix.tcsetattr(fd, .TCSAFLUSH, &attr) != .OK {
return state, false
}
return state, true
}
disable_raw_mode :: proc(state: ^Raw_State) {
posix.tcsetattr(state.fd, .TCSAFLUSH, &state.original)
}
read_key :: proc() -> Key {
buf: [3]u8
n := posix.read(posix.STDIN_FILENO, &buf[0], 1)
if n <= 0 {
return .Unknown
}
switch buf[0] {
case ' ':
return .Space
case '\n', '\r':
return .Enter
case 0x03:
return .Escape
case 0x1b:
tv: posix.timeval
tv.tv_sec = 0
tv.tv_usec = posix.suseconds_t(100000)
set: posix.fd_set
posix.FD_ZERO(&set)
posix.FD_SET(posix.STDIN_FILENO, &set)
ready := posix.select(1, &set, nil, nil, &tv)
if ready <= 0 {
return .Escape
}
n2 := posix.read(posix.STDIN_FILENO, &buf[1], 1)
if n2 <= 0 || buf[1] != '[' {
return .Escape
}
posix.FD_ZERO(&set)
posix.FD_SET(posix.STDIN_FILENO, &set)
tv.tv_sec = 0
tv.tv_usec = posix.suseconds_t(100000)
ready = posix.select(1, &set, nil, nil, &tv)
if ready <= 0 {
return .Escape
}
n3 := posix.read(posix.STDIN_FILENO, &buf[2], 1)
if n3 <= 0 {
return .Escape
}
switch buf[2] {
case 'A':
return .Up
case 'B':
return .Down
case:
return .Escape
}
case:
return .Unknown
}
}

View File

@@ -8,6 +8,9 @@ OK :: 0
ROW :: 100 ROW :: 100
DONE :: 101 DONE :: 101
DESERIALIZE_FREEONCLOSE :: 1
DESERIALIZE_RESIZEABLE :: 2
foreign lib { foreign lib {
@(link_name="sqlite3_open") @(link_name="sqlite3_open")
db_open :: proc(filename: cstring, ppDb: ^^rawptr) -> c.int --- db_open :: proc(filename: cstring, ppDb: ^^rawptr) -> c.int ---
@@ -31,4 +34,12 @@ foreign lib {
bind_text :: proc(stmt: ^rawptr, idx: c.int, val: cstring, n: c.int, destructor: rawptr) -> c.int --- bind_text :: proc(stmt: ^rawptr, idx: c.int, val: cstring, n: c.int, destructor: rawptr) -> c.int ---
@(link_name="sqlite3_changes") @(link_name="sqlite3_changes")
changes :: proc(db: ^rawptr) -> c.int --- changes :: proc(db: ^rawptr) -> c.int ---
@(link_name="sqlite3_serialize")
serialize :: proc(db: ^rawptr, zSchema: cstring, piSize: ^i64, mFlags: u32) -> [^]u8 ---
@(link_name="sqlite3_deserialize")
deserialize :: proc(db: ^rawptr, zSchema: cstring, pData: [^]u8, szDb: i64, szBuf: i64, mFlags: u32) -> c.int ---
@(link_name="sqlite3_malloc64")
malloc64 :: proc(n: i64) -> [^]u8 ---
@(link_name="sqlite3_free")
free :: proc(p: rawptr) ---
} }

View File

@@ -116,12 +116,22 @@ test_render_table_normal :: proc(t: ^testing.T) {
output := strings.to_string(b) output := strings.to_string(b)
testing.expect(t, strings.contains(output, "Name"), "header 'Name' missing from output") expected := `┌──────┬─────────────────────────┐
testing.expect(t, strings.contains(output, "Path"), "header 'Path' missing from output") │ Name │ Path │
testing.expect(t, strings.contains(output, "foo"), "cell 'foo' missing from output") ├──────┼─────────────────────────┤
testing.expect(t, strings.contains(output, "/home/user/.env"), "cell '/home/user/.env' missing from output") │ foo │ /home/user/.env │
testing.expect(t, strings.contains(output, "bar"), "cell 'bar' missing from output") │ bar │ /home/user/project/.env │
testing.expect(t, strings.contains(output, "/home/user/project/.env"), "cell '/home/user/project/.env' missing") └──────┴─────────────────────────┘
`
testing.expect(
t,
output == expected,
fmt.tprintf(
"table output mismatch\n--- expected ---\n%s\n--- got ---\n%s\n",
expected,
output,
),
)
} }
@(test) @(test)
@@ -138,7 +148,20 @@ test_render_table_empty :: proc(t: ^testing.T) {
output := strings.to_string(b) output := strings.to_string(b)
testing.expect(t, strings.contains(output, "Name"), "header 'Name' missing from output") expected := `┌──────┐
│ Name │
├──────┤
└──────┘
`
testing.expect(
t,
output == expected,
fmt.tprintf(
"table output mismatch\n--- expected ---\n%s\n--- got ---\n%s\n",
expected,
output,
),
)
} }
@(test) @(test)
@@ -155,7 +178,21 @@ test_render_table_unicode :: proc(t: ^testing.T) {
output := strings.to_string(b) output := strings.to_string(b)
testing.expect(t, strings.contains(output, "Available"), "unicode cell content missing") expected := `┌─────────────┬────────┐
testing.expect(t, strings.contains(output, "Missing"), "unicode cell content missing") │ Status │ Detail │
├─────────────┼────────┤
│ ✓ Available │ ok │
│ ✗ Missing │ fail │
└─────────────┴────────┘
`
testing.expect(
t,
output == expected,
fmt.tprintf(
"table output mismatch\n--- expected ---\n%s\n--- got ---\n%s\n",
expected,
output,
),
)
} }

View File

@@ -1 +1 @@
0.2.0 0.3.0