84 Commits

Author SHA1 Message Date
spencer d62123e831 docs(README.md): Fixed broken link. 2026-07-27 17:43:39 -04:00
spencer c50d490d9b docs: Added AI Disclaimer. 2026-07-27 17:42:21 -04:00
spencer a4c96265f8 chore: Added TODOs. 2026-07-27 13:53:20 -04:00
Spencer Brower 3813719c84 chore(prerelease): release 1.0.0 2026-07-27 13:43:48 -04:00
spencer 18e6b0b5b1 style: Cleaned up vet errors. 2026-07-27 13:35:09 -04:00
spencer 149902cf91 refactor: re-ordered procs by calling order. 2026-07-27 13:31:35 -04:00
spencer 86a4da6cba feat: Brought back the docs generator. 2026-07-27 13:12:31 -04:00
spencer 222a0a3452 feat: Added uninstall command. 2026-07-27 13:12:31 -04:00
spencer d4c935ec07 test: Added a test to ensure db updates don't break hardlinks. 2026-07-13 13:54:06 -04:00
spencer e0a339e8ec feat: Added bash completion support. 2026-07-13 13:35:05 -04:00
spencer b0635e035c refactor!: Renamed nushell-completion command to completion nushell. 2026-07-13 13:12:59 -04:00
spencer 997f11b257 perf: Added a pre-alloc opportunity. 2026-07-03 22:12:08 -04:00
spencer f5624e91f1 perf: Added #no_bounds_check to ssh parsers. 2026-07-03 21:43:06 -04:00
spencer 80353f387a chore: Updated todos. 2026-07-01 17:01:21 -04:00
spencer aae1495d59 refactor: Declared commands with named fields instead of positional. 2026-07-01 14:02:36 -04:00
spencer 209ec89add feat: Improved nushell wrapper output. 2026-07-01 14:02:36 -04:00
spencer 7ffc38171c chore: Updated TODOS.md 2026-07-01 11:23:42 -04:00
spencer 8b9a9789ab refactor: Removed needless guards from write_flags_table. 2026-07-01 11:11:24 -04:00
spencer 6244a6c8ce refactor(ssh.odin): removed offset from parser logic. 2026-07-01 10:49:36 -04:00
spencer 7aaa415777 test: Fixed leak in test. 2026-06-30 13:27:38 -04:00
spencer ca992f05f4 refactor(findr): Now accepts an allocator parameter. 2026-06-30 13:27:38 -04:00
spencer ae42d53f02 feat: Scan results can now be filtered during selection. 2026-06-30 12:31:28 -04:00
spencer af1860c970 fix: Removed irrelevent flags from nushell-completion command help text. 2026-06-29 18:17:56 -04:00
spencer 5282a4414b perf!: Removed support for JSON formatted remotes.
BREAKING CHANGE: Databases with a JSON formatted remotes column will no
longer parse correctly. If you have an old database, make sure you
upgrade to envr 0.4.0 and run `envr list` before upgrading to this
version.
2026-06-29 18:12:11 -04:00
spencer ea1627ec2e build: nix version is now pinned to version.txt. 2026-06-29 18:12:10 -04:00
Spencer Brower d978cb6869 chore(main): release 0.4.0 2026-06-29 17:38:20 -04:00
spencer 70b5c0e184 ci: Updated release-please. 2026-06-29 17:32:55 -04:00
spencer fb90342126 fix: Flags in help text are now customized per command. 2026-06-29 17:32:55 -04:00
spencer 17d7fb5eb6 refactor: Removed hardcoded colors. 2026-06-29 17:32:55 -04:00
spencer c3e667e7bc feat: Added --color flag. 2026-06-29 17:32:55 -04:00
spencer 16819f65cb refactor: Used RTTI for more sophisticated flag parsing. 2026-06-29 17:32:55 -04:00
spencer 4d3da01f65 nix: Fixed the nix build. 2026-06-29 17:32:54 -04:00
spencer 2b68617b5d fix: -h short flag now works on subcommands. 2026-06-29 17:32:54 -04:00
spencer 36f124a4fd chore: Re-numbered todos. 2026-06-29 17:32:54 -04:00
spencer bb84c56c98 feat: Added --format, -f flag.
Allows printing data in tabular or json format.
2026-06-29 17:32:54 -04:00
spencer 6fa68d10b1 style: Ignored allocation errors where possible. 2026-06-25 17:34:34 -04:00
spencer 13e9495642 refactor: Replaced fmt.printf calls with fmt.eprintf. 2026-06-25 17:21:14 -04:00
spencer ad3de74e35 test: Rewrote expect checks to use expect_value where appropriate. 2026-06-25 17:13:50 -04:00
spencer 0b5bf4db73 perf: Improved the performance of table rendering. 2026-06-25 10:22:00 -04:00
spencer 96b3d6340a perf: remotes are now stored as a newline delimited list.
Previously they were saved as json.
2026-06-25 10:22:00 -04:00
spencer 5cc7973775 fix: Used os path separator rather than '/' where appropriate. 2026-06-24 17:55:54 -04:00
spencer f825bc2b09 fix: Databases errors are less likely to go unnoticed. 2026-06-24 17:38:13 -04:00
spencer d43b6a75a7 chore: Updated TODOS numbers. 2026-06-24 17:07:19 -04:00
spencer 5bc776dd70 refactor: Removed PascalCase names. 2026-06-24 17:06:14 -04:00
spencer bd39e93785 refactor(cli): write_usage and write_command_help now use text/table. 2026-06-24 16:58:12 -04:00
spencer 91d0800731 test: Simplified temp directory creaation. 2026-06-24 15:49:33 -04:00
spencer cd3e1b1110 test: Fixed scan_test. 2026-06-24 15:14:12 -04:00
spencer bb6c067b97 refactor: App now crashes if home isn't set. 2026-06-24 14:35:05 -04:00
spencer 3331a40053 refactor: Simplified absolute path resolution code. 2026-06-24 14:06:42 -04:00
spencer de1594d9d1 fix: Handled mk_dir error. 2026-06-24 13:46:25 -04:00
spencer dc72ff56fd fix: Fixed some leaks in backup and scan. 2026-06-24 13:28:15 -04:00
spencer 78984b57ff refactor: Ignored allocation errors. 2026-06-24 13:08:52 -04:00
spencer 9256d94f70 chore: Handled decoding errors. 2026-06-24 11:49:06 -04:00
spencer a11925e720 refactor(ssh): Partially cleaned up. 2026-06-24 11:42:31 -04:00
spencer 6139485d13 chore(ssh): Removed is_encrypted_key. 2026-06-22 10:17:28 -04:00
spencer 4fcd0b3c9d chore: Cleaned up some files. 2026-06-22 09:28:30 -04:00
spencer 63d00a1f55 refactor(config): Switched property names to camel_case. 2026-06-22 09:20:11 -04:00
spencer 29415da692 chore: Re-numbered todos. 2026-06-21 23:10:29 -04:00
spencer f703a8df5d refactor(db.odin): Renamed fields for consistency. 2026-06-21 22:58:43 -04:00
spencer 2683e2a00f refactor(sqlite): Used distinct types for Db and Stmt pointers.
Also made some other improvements to it.
2026-06-21 16:52:21 -04:00
spencer 9683216efe refactor(sqlite): Removed db_ prefix from db_open and db_close. 2026-06-20 18:49:56 -04:00
spencer 92faab2706 refactor: Used the official table package. 2026-06-19 19:35:42 -04:00
spencer f2da8b9f22 refactor: Used ansi project constants instead of inlines. 2026-06-19 18:17:42 -04:00
spencer 4097e37d9f chore: Made some code more windows friendly. 2026-06-19 18:09:40 -04:00
spencer f5eeb55dd1 refactor: Removed dead code. 2026-06-19 18:09:40 -04:00
spencer e4b32a9909 test: Added spall config back. 2026-06-19 17:33:43 -04:00
spencer 1562fb3665 fix: Fixed vet errors. 2026-06-19 17:33:43 -04:00
spencer c7c254f6f2 fix: Fixed leaks. 2026-06-19 15:32:44 -04:00
spencer 0083e4e0db fix(scan): Fixed a bug preventing TUI from working. 2026-06-19 14:39:53 -04:00
spencer 33cd7c4eda feat: Colorized console output. 2026-06-19 13:45:55 -04:00
spencer a03d388a0c refactor: Allocations now use the temp_allocator more frequently. 2026-06-19 07:50:57 -04:00
spencer 84764d03a6 refactor: Cleaned up the sync and scan commands. 2026-06-19 07:29:51 -04:00
spencer 0523c09601 refactor: Gave db its own allocator. 2026-06-18 17:29:28 -04:00
spencer f137fc79fc refactor: Fixed up env_file_sync. 2026-06-18 16:35:03 -04:00
spencer 8d5e50566b ci: Fixed release-please. 2026-06-18 10:43:47 -04:00
spencer 5059572951 fix: Fixed memory leaks in the db. 2026-06-18 10:35:21 -04:00
spencer d2b84ac4c6 refactor(crypto): used a proper init procedure. 2026-06-18 08:18:09 -04:00
spencer 96bc218c46 style: Ordered procedures by usage, with main at the top. 2026-06-18 08:08:46 -04:00
spencer 3b32e365c9 chore: Updated TODOS.md 2026-06-18 07:45:38 -04:00
spencer 12574e123b feat: Removed runtime git dependency.
This also allowed us to drop the Features code.
2026-06-18 07:29:44 -04:00
spencer bc464a3410 chore: Removed completed todo. 2026-06-17 18:03:54 -04:00
spencer 2ef733fe58 perf: Replaced fd with custom internals. 2026-06-17 17:56:31 -04:00
spencer 159ff91938 build: Incremented flake version number. 2026-06-16 11:56:10 -04:00
Spencer Brower 84550d4708 chore(main): release 0.3.0 2026-06-16 11:43:36 -04:00
79 changed files with 5333 additions and 2877 deletions
+2
View File
@@ -1 +1,3 @@
use flake
export PATH="./:$PATH"
+1 -2
View File
@@ -2,8 +2,7 @@ on:
push:
branches:
- main
- dev
- odin
- prerelease
permissions:
contents: write
+6
View File
@@ -7,8 +7,14 @@ list.json
man
# build artifacts
*.spall
builds
envr
envr-go
envr-prof
findr/findr
findr/findr-prof
findr/bench-*.md
gendocs
result
version.odin
+68
View File
@@ -1,7 +1,75 @@
# Changelog
## [1.0.0](https://github.com/sbrow/envr/compare/v0.4.0...v1.0.0) (2026-07-27)
### ⚠ BREAKING CHANGES
* Renamed `nushell-completion` command to `completion nushell`.
* Databases with a JSON formatted remotes column will no longer parse correctly. If you have an old database, make sure you upgrade to envr 0.4.0 and run `envr list` before upgrading to this version.
### Features
* Added `uninstall` command. ([222a0a3](https://github.com/sbrow/envr/commit/222a0a3452b978d9bd14d6d2b91e40cd8f4959e3))
* Added bash completion support. ([e0a339e](https://github.com/sbrow/envr/commit/e0a339e8ec996c0e91e27357854abd9a78bd695a))
* Brought back the docs generator. ([86a4da6](https://github.com/sbrow/envr/commit/86a4da6cbaae698379c73b8345e6d238b2a11a46))
* Improved nushell wrapper output. ([209ec89](https://github.com/sbrow/envr/commit/209ec89addbed4718c5aed845ab20b386d1e425d))
* Scan results can now be filtered during selection. ([ae42d53](https://github.com/sbrow/envr/commit/ae42d53f029dd185143a645fb3f3a787051c1bb4))
### Bug Fixes
* Removed irrelevent flags from `nushell-completion` command help text. ([af1860c](https://github.com/sbrow/envr/commit/af1860c970604470992182bbfe9ecb15609a0acf))
### Performance Improvements
* Added `#no_bounds_check` to ssh parsers. ([f5624e9](https://github.com/sbrow/envr/commit/f5624e91f1236eecb263f28600ce7f4b2fda149f))
* Added a pre-alloc opportunity. ([997f11b](https://github.com/sbrow/envr/commit/997f11b257f1c981f73c6ae97d6e821147b5e2fc))
* Removed support for JSON formatted remotes. ([5282a44](https://github.com/sbrow/envr/commit/5282a4414b9a0211604553f2cb9434025aa5296b))
### Code Refactoring
* Renamed `nushell-completion` command to `completion nushell`. ([b0635e0](https://github.com/sbrow/envr/commit/b0635e035c44fb20f9b02265cff1ed8db30aa9d0))
## [0.4.0](https://github.com/sbrow/envr/compare/v0.3.0...v0.4.0) (2026-06-29)
### Features
* Added `--color` flag. ([c3e667e](https://github.com/sbrow/envr/commit/c3e667e7bca660b21b5851460691ec8bd5026f80))
* Added `--format`, `-f` flag. ([bb84c56](https://github.com/sbrow/envr/commit/bb84c56c98bead9ce9bad3f89fb3b61b53d05a10))
* Colorized console output. ([33cd7c4](https://github.com/sbrow/envr/commit/33cd7c4eda43287fe3dc2d2289a4e1531b524ef6))
* Removed runtime git dependency. ([12574e1](https://github.com/sbrow/envr/commit/12574e123bdedba3aca813143e906ec5e0b95719))
### Bug Fixes
* -h short flag now works on subcommands. ([2b68617](https://github.com/sbrow/envr/commit/2b68617b5d6c17444b04307d7b4b7f4a3aefb978))
* Databases errors are less likely to go unnoticed. ([f825bc2](https://github.com/sbrow/envr/commit/f825bc2b096632bb258d5681b1072941e9d4233d))
* Fixed leaks. ([c7c254f](https://github.com/sbrow/envr/commit/c7c254f6f2ac871182f3fcc233766884753e2049))
* Fixed memory leaks in the db. ([5059572](https://github.com/sbrow/envr/commit/5059572951b3ec20b3d2027032a9c3be5cb14dba))
* Fixed some leaks in `backup` and `scan`. ([dc72ff5](https://github.com/sbrow/envr/commit/dc72ff56fd0e165930771682b2fd266eef3e7e16))
* Fixed vet errors. ([1562fb3](https://github.com/sbrow/envr/commit/1562fb3665b8704dcffd4944047f7984b308e52e))
* Flags in help text are now customized per command. ([fb90342](https://github.com/sbrow/envr/commit/fb903421265baa65adbbe092f6bf797639f1fb1f))
* Handled mk_dir error. ([de1594d](https://github.com/sbrow/envr/commit/de1594d9d1fe46dd9ebc3be01fc5a5ebaf4064e5))
* **scan:** Fixed a bug preventing TUI from working. ([0083e4e](https://github.com/sbrow/envr/commit/0083e4e0dbc5b949a3cf4cd8b64af4f698ed9d33))
* Used os path separator rather than '/' where appropriate. ([5cc7973](https://github.com/sbrow/envr/commit/5cc79737753f0f19db2bc55304ea5f70237cbffd))
### Performance Improvements
* Improved the performance of table rendering. ([0b5bf4d](https://github.com/sbrow/envr/commit/0b5bf4db73113bdd43d8a225ec0ae53fedd25918))
* remotes are now stored as a newline delimited list. ([96b3d63](https://github.com/sbrow/envr/commit/96b3d6340a95b67c884ba940b46461e51989fcbb))
* Replaced `fd` with custom internals. ([2ef733f](https://github.com/sbrow/envr/commit/2ef733fe58594b0a0b6e3ef85142b74af445ccb8))
## [0.3.0](https://github.com/sbrow/envr/compare/v0.2.1...v0.3.0) (2026-06-16)
Version 0.3.0 represents a significant departure (and improvement) for envr.
The entire codebase was rewritten in [Odin](https://odin-lang.org/) (from Go).
This reduced the binary size from over 17MB to under 600k, improved performance,
and significantly reduced the number of project dependencies from 69 to just 2.
### ⚠ BREAKING CHANGES
+9 -2
View File
@@ -2,7 +2,7 @@
# Builds release artifacts for GitHub releases
APP_NAME := envr
VERSION := $(shell grep 'version = ' flake.nix | head -1 | sed 's/.*version = "\(.*\)";/\1/')
VERSION := $(file < version.txt)
BUILD_DIR := builds
# Binary names
@@ -10,7 +10,7 @@ LINUX_AMD64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-amd64
LINUX_ARM64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-linux-arm64
DARWIN_ARM64_BIN := $(BUILD_DIR)/$(APP_NAME)-$(VERSION)-darwin-arm64
.PHONY: all clean cleanall build-linux build-darwin compress release help
.PHONY: all clean cleanall build-linux build-darwin compress release profile help
# Default target
all: release clean
@@ -66,6 +66,12 @@ release: build-linux compress
@echo "Release artifacts created:"
@ls -la $(BUILD_DIR)/*.tar.gz $(BUILD_DIR)/*.zip 2>/dev/null || echo "No compressed artifacts found"
# Build with spall profiling instrumentation
profile:
@echo "Building with spall profiling..."
odin build . -define:SPALL=true -o:speed -out:envr-prof
@echo "Built envr-prof (run it to generate envr.spall)"
# Clean binary files only
clean:
@echo "Cleaning binary files..."
@@ -84,6 +90,7 @@ help:
@echo " build-linux - Build Linux binaries only"
@echo " build-darwin - Build Darwin binaries only"
@echo " compress - Compress all built binaries"
@echo " profile - Build with spall profiling instrumentation"
@echo " clean - Remove binary files only"
@echo " cleanall - Remove entire build directory"
@echo " help - Show this help message"
+32 -14
View File
@@ -10,16 +10,17 @@ in an encyrpted sqlite database. Changes can be effortlessly synced with
`envr` puts all your .env files in one safe place, so you can back them up with
the tool [of your choosing](#backup-options).
Please read the [AI Disclosure](#ai-disclosure) if you're concerned about AI code.
## Features
- 🔐 **Encrypted Storage**: All `.env` files are encrypted using your ssh key and
[libsodium](https://github.com/jedisct1/libsodium) encryption.
- 🔄 **Automatic Sync**: Update the database with one command, which can easily
- **Encrypted Storage**: All `.env` files are encrypted using your ssh key and
[libsodium](https://github.com/jedisct1/libsodium).
- **Automatic Sync**: Update the database with one command, which can easily
be run on a cron.
- 🔍 **Smart Scanning**: Automatically discover and import `.env` files in your
- **Smart Scanning**: Automatically discover and import `.env` files in your
home directory.
- **Interactive CLI**: User-friendly prompts for file selection and management.
- 🗂️ **Rename Detection**: Automatically finds and updates renamed/moved
- **Rename Detection**: Automatically find and updates renamed/moved
repositories.
## TODOS
@@ -28,15 +29,11 @@ repositories.
- [x] Allow configuration of ssh key.
- [x] Allow multiple ssh keys.
## Prerequisites
- An SSH key pair (for encryption/decryption)
- The following binaries:
- [fd](https://github.com/sharkdp/fd)
- [git](https://git-scm.com)
## Installation
You will need an SSH key pair for encryption and decryption. You can generate one
with `ssh-keygen -t ed25519`. It will be saved to `~/.ssh/id_ed25519`.
### With Odin
If you already have `odin` installed:
@@ -96,7 +93,12 @@ The configuration file is created during initialization:
],
"scan": {
"matcher": "\\.env",
"exclude": "*.envrc",
"exclude": [
"*\\.envrc",
"\\.local/",
"node_modules",
"vendor"
],
"include": "~"
}
}
@@ -125,6 +127,22 @@ location.
[restic](https://restic.readthedocs.io/en/latest/010_introduction.html).
## AI Disclosure
This project was largely built by AI. The original project (written in go) was
written almost entirely with AI, using libraries that I requested (i.e. cobra).
I wrote it at work because I needed the tool and wanted it fast. The Odin version
was also primarily written by AI, but I stepped in a lot more, and took care to
architect the project in the way that I wanted, and made sure tests were written
to catch leaks.
Most of the documentation was written by hand, but I think the original `README.md`
was at least fleshed out by AI.
It is by no means perfect, but I have spent a lot of time doing code review and
testing to make sure the program (and documentation) meet my personal standards.
## License
This project is licensed under the [MIT License](./LICENSE).
-268
View File
@@ -1,268 +0,0 @@
# Table Rendering Memory Optimization Plan
## Executive Summary
This plan outlines improvements to eliminate excessive memory allocations and copies in the Odin table rendering system. The current implementation makes 10+ allocations per row, while the Zig equivalent makes zero allocations for rendering. This optimization will reduce memory usage, improve performance, and align with the project's efficiency goals.
## Current State Analysis
### Zig Version (Reference Implementation)
- **Allocations**: 1 (data only)
- **Data copies**: 0
- **String allocation**: 0
- **Column widths**: Stack array
- **Output**: Direct to writer
### Odin Version (Current Implementation)
- **Allocations**: 10+ per row
- **Data copies**: Multiple per row
- **String allocation**: 2+ per row (concatenate + slice)
- **Column widths**: Heap allocated
- **Output**: Builder → stdout
### Current Issues Identified
1. **Table Infrastructure** (`table.odin`)
- Uses `strings.Builder` which allocates per-line memory
- Heap-allocated `[dynamic]int` for column widths
- Multiple `strings.concatenate()` calls creating new strings
2. **Command Implementations**
- `cmd_list`: Creates intermediate `[]string` slices per row, allocates new strings via `strings.concatenate()`
- `cmd_sync`: Creates `SyncEntry` structs with cloned strings, allocates dynamic arrays
- `cmd_deps`: Allocates dynamic rows array unnecessarily
3. **Memory Pattern**
- Each command allocates `[][]string` for table data
- Manual struct-to-row transformation creates copies
- Duplicate code across all table-using commands
## Proposed Solutions
### Phase 1: Core Table Infrastructure Overhaul
#### 1.1 Direct Writer-Based Rendering
**Current:**
```odin
b: strings.Builder
strings.builder_init(&b)
// ... build table in builder
fmt.println(strings.to_string(b))
```
**Proposed:**
```odin
render_table :: proc(writer: io.Writer, headers: []string, rows: [][]string)
```
- Replace `strings.Builder` with `io.Writer` output
- Eliminate intermediate string allocations
- Write table components directly to output stream
#### 1.2 Stack-Based Column Widths
**Current:**
```odin
col_widths := make([dynamic]int, 0, len(headers))
```
**Proposed:**
- Use fixed stack arrays for reasonable column counts
- Implement small buffer optimization (SBO) for variable column counts
- Only allocate for tables exceeding threshold (e.g., 16 columns)
#### 1.3 Zero-Copy String Handling
**Current:**
```odin
dir_str := strings.concatenate({row.Dir, "/"}, context.temp_allocator)
```
**Proposed:**
- Replace `strings.concatenate()` with string slicing
- Work directly with `EnvFile.Path` and `EnvFile.Dir` fields
- Use `filepath.base()` and `filepath.dir()` without allocation where possible
### Phase 2: Generic Table Interface
#### 2.1 Field-Based Table Renderer
```odin
Table_Field :: struct {
name: string,
value: string, // String view, no allocation
alignment: Alignment,
}
Table_Config :: struct {
writer: io.Writer,
fields: []Table_Field,
col_widths: []int,
}
render_row :: proc(cfg: Table_Config, row_data: any)
```
- Accept struct fields directly without intermediate arrays
- Support field selection (show only specific fields)
- Alignment options (left/center/right)
#### 2.2 Field Extraction Procs
- Generate field extraction helpers for each struct type
- Avoid string allocation by returning string views
- Cache computed values (like formatted status strings)
#### 2.3 Streaming Table Processing
- Process rows one at a time without collecting all rows
- Reduce peak memory usage from O(N × strings) to O(table_structure)
- Enable early termination if needed
### Phase 3: Command-Specific Optimizations
#### 3.1 Eliminate Intermediate Structs
**Current (cmd_sync):**
```odin
for &file in files {
// ... processing
path_str, _ := strings.clone(file.Path)
status_str, _ := strings.clone(status)
append(&results, SyncEntry{Path = path_str, Status = status_str})
}
```
**Proposed:**
```odin
for &file in files {
result, err_msg := db_sync(&db, &file)
// Direct rendering with zero-copy
render_sync_row(writer, file, result, err_msg)
}
```
- `cmd_sync`: Work directly with `EnvFile` + `SyncFlagEnum`
- `cmd_list`: Use `EnvFile` fields directly, no `ListEntry`
- Generate table content on-the-fly
#### 3.2 In-Place Status Computation
```odin
get_sync_status :: proc(result: SyncFlag, err_msg: string) -> string {
switch {
case .Error in result: return if len(err_msg) > 0 then err_msg else "error"
case .BackedUp in result: return "Backed Up"
case .Restored in result: return "Restored"
case .DirUpdated in result: return "Moved"
case: return "OK"
}
}
```
- Compute status strings without allocation (use static lookup)
- Cache formatted status values if needed
- Reduce allocation count from N to 0 or 1
#### 3.3 Batch Processing
- Reduce allocation count by pooling small allocations
- Use `context.temp_allocator` more effectively
- Pre-allocate buffers for expected sizes
### Phase 4: JSON Output Separation
#### 4.1 Unified JSON Rendering
```odin
render_json_rows :: proc(writer: io.Writer, rows: any, field_names: []string)
```
- Create centralized JSON rendering helper
- Work with same structs as table rendering
- Use reflection or explicit field marshaling
#### 4.2 Format-Agnostic Interface
- Commands generate data → renderers handle format
- Table renderer focuses only on ASCII/Unicode output
- Keep terminal detection in command layer
## Expected Improvements
| Metric | Current | Target | Improvement |
|--------|---------|--------|-------------|
| **Allocations** | 10+ per row | 0-1 per table | 10x+ reduction |
| **Memory copies** | 2-3 per row | 0 | 100% reduction |
| **Peak memory** | O(N × strings) | O(table_structure) | Constant factor |
| **Throughput** | Baseline | 2-3x faster | Performance boost |
## Implementation Strategy
### High-Priority Changes
1. Replace `strings.Builder` with direct `io.Writer` output
2. Convert column widths to stack-based allocation
3. Eliminate intermediate struct allocations in commands
### Medium-Priority Changes
1. Create generic field-based table interface
2. Implement streaming table processing
3. Centralize JSON rendering logic
### Low-Priority Changes
1. Add alignment options beyond left-aligned
2. Implement comprehensive field introspection
3. Add advanced table formatting features
## Tradeoff Questions
Before implementation begins, we need to resolve these architectural questions:
### 1. Generality vs. Performance
**Question:** Should we create a fully generic table renderer (similar to Zig's `Table(T)`) or focus on optimizing the current 3 use cases first?
**Options:**
- **Generic approach**: Higher development cost, future-proof, may have some overhead
- **Specific optimization**: Faster implementation, maximum performance for current use cases, less flexible
**Recommendation:** Start with specific optimizations for current use cases, then generalize patterns that emerge.
### 2. Alignment Support
**Question:** Does the project need left/center/right alignment support, or is left-alignment sufficient?
**Context:** Zig supports alignment options, but current Odin implementation only left-aligns. Most CLI tables work fine with left alignment.
**Recommendation:** Start with left-alignment only, add alignment if specific use cases demand it.
### 3. API Compatibility
**Question:** Should we maintain the current `render_table()` API signature, or are breaking changes acceptable?
**Current API:**
```odin
render_table :: proc(headers: []string, rows: [][]string)
```
**Options:**
- **Maintain API**: Slower to implement, backward compatible, may need adapter layers
- **Break API**: Faster implementation, cleaner code, requires updates to all callers
**Recommendation:** Breaking changes are acceptable since this is an optimization-focused effort and callers are limited to 3 commands.
### 4. Odin Capabilities
**Question:** What runtime reflection or field introspection capabilities does Odin provide?
**Context:** Zig uses `@typeInfo()` and comptime field iteration. We need to understand Odin's equivalent capabilities to design the optimal solution.
**Recommendation:** Investigate Odin's runtime type information capabilities before finalizing the generic table interface design.
### 5. Testing Strategy
**Question:** Should we add comprehensive tests for new table rendering before optimizing commands, or optimize incrementally with tests added afterwards?
**Options:**
- **Test-first**: More robust, catches regressions early, slower initial development
- **Optimize-first**: Faster development, may miss edge cases, requires retroactive testing
**Recommendation:** Hybrid approach - add basic tests for core infrastructure, then optimize incrementally with additional tests for each command.
## Next Steps
1. **Research Phase**: Investigate Odin's type system and reflection capabilities
2. **Prototype Phase**: Create minimal working prototype of zero-allocation table renderer
3. **Refactor Phase**: Incrementally update commands to use new infrastructure
4. **Test Phase**: Add comprehensive tests and verify memory improvements
5. **Benchmark Phase**: Measure performance improvements and memory usage
## Success Criteria
- [ ] Zero allocations for table rendering (excluding initial data)
- [ ] Zero string copies in the happy path
- [ ] All 3 commands (`list`, `sync`, `deps`) use new infrastructure
- [ ] Performance improvement of 2x or more
- [ ] Memory usage reduction of 50% or more
- [ ] No regression in table formatting quality
- [ ] Backward compatibility with JSON output format
-7
View File
@@ -35,13 +35,7 @@ Stdout will be captured by redirecting `os.stdout` to a pipe.
## Hard to test (interactive / external deps)
### `cmd_deps` (cmd_deps.odin)
- Needs `git` and/or `fd` in PATH
- Test TTY and non-TTY paths
- Skip if dependencies not available (with `#assert` like TODO 28 suggests)
### `cmd_scan` (cmd_scan.odin)
- Needs `fd` installed
- Test with fixture git repo containing `.env` files
- Test `find_unbacked` integration (already partially tested in `cmd_check_test.odin`)
- Non-TTY JSON output path
@@ -66,5 +60,4 @@ Stdout will be captured by redirecting `os.stdout` to a pipe.
- DB integration tests should use in-memory SQLite (`:memory:`) where possible.
- Temp dir fixtures should follow the pattern in `scan_test.odin`.
- External dependency tests (`fd`, `git`) should use `#assert` to ensure the dependency is present rather than silently skipping (TODO 28).
- Tests that manipulate the `HOME` env var must use a mutex to prevent races with parallel test execution.
+22 -43
View File
@@ -1,57 +1,29 @@
# TODOs
1. Bring back windows support / cross-compilation.
1. Consider giving db its own allocator
2. Commands are still leaking. (Write tests for everything first)
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. procedures should be ordered by use, main at the top, then in the order they are called from main.
3. **db.odin:135, 250** — String interpolation into SQL (`VACUUM INTO '%s'`, `ATTACH DATABASE '%s'`). Currently safe because input is controlled, but fragile.
4. Test all command branches.
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).
6. Add tests for untested commands.
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.
7. Add purge command?
6. **cmd_restore.odin:44**`os.mkdir_all` error silently discarded. Subsequent write failure will be confusing.
8. Add docs for "building from source"
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.
10. **db.odin:115**`json.unmarshal_string` error not checked. Malformed JSON silently produces empty/partial data.
11. **db.odin:352-353**`hex.encode` error ignored. `string(hex_bytes)` aliases the byte slice.
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.
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"`.
14. Check for prealloc opportunities. i.e. `make([dynamic]string)` -> `make([dynamic]string, 5)`.
15. Add a text filter to the multi_select.
16. Create backup / fallback fd.
17. Add tests for untested commands.
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.
19. Try to do all encryption / decryption in memory - only read / write encrypted data to disk.
20. add --format -f flag to commands that draw tables.
21. Replace `testing.expect` calls with `testing.expect_value` calls where appropriate.
22. Change struct field names from PascalCase to snake_case.
23. procedures should be ordered by use, main at the top, then in the order they are called from main.
## Double-check AI output
- [ ] cli.odin
- [ ] cli_test.odin
- [x] colors.odin
- [x] cmd_backup.odin
- [x] cmd_check.odin
- [ ] cmd_check_test.odin
- [x] cmd_deps.odin
- [ ] cmd_edit_config.odin
- [x] cmd_edit_config.odin
- [x] cmd_init.odin
- [x] cmd_list.odin
- [ ] cmd_list_test.odin
@@ -62,22 +34,29 @@
- [x] cmd_scan.odin
- [x] cmd_sync.odin
- [x] cmd_version.odin
- [ ] config.odin
- [x] config.odin
- [ ] config_test.odin
- [ ] crypto.odin
- [ ] crypto_test.odin
- [ ] db.odin
- [ ] db_integration_test.odin
- [ ] db_test.odin
- [x] features.odin
- [x] features_test.odin
- [ ] flags.odin
- [x] main.odin
- [x] prompt.odin
- [ ] scan.odin
- [ ] prompt.odin
- [x] scan.odin
- [ ] scan_test.odin
- [ ] sodium.odin
- [ ] sqlite/sqlite.odin
- [x] sqlite/sqlite.odin
- [ ] ssh.odin
- [ ] ssh_test.odin
- [ ] table.odin
- [ ] table_test.odin
- [ ] findr/findr_test.odin
- [ ] findr/gitignore.odin
- [ ] findr/gitignore_test.odin
- [ ] findr/glob.odin
- [ ] findr/glob_test.odin
- [ ] findr/repos.odin
- [ ] findr/test_env.odin
- [ ] findr/walker.odin
-92
View File
@@ -1,92 +0,0 @@
# Windows Compatibility Guide
This document outlines Windows compatibility issues and solutions for the envr project.
## Critical Issues
### 1. Path Handling Bug (MUST FIX)
**File:** `app/env_file.go:209`
**Issue:** Uses `path.Join` instead of `filepath.Join`, which won't work correctly on Windows due to different path separators.
**Current code:**
```go
f.Path = path.Join(newDir, path.Base(f.Path))
```
**Fixed code:**
```go
f.Path = filepath.Join(newDir, filepath.Base(f.Path))
```
## External Dependencies
The application relies on external tools that need to be installed separately on Windows:
### Required Tools
1. **fd** - Fast file finder
- Install via: `winget install sharkdp.fd` or `choco install fd`
- Alternative: `scoop install fd`
2. **git** - Version control system
- Install via: `winget install Git.Git` or download from git-scm.com
- Usually already available on most development machines
## Minor Compatibility Notes
### File Permissions
- Unix file permissions (`0755`, `0644`) are used throughout the codebase
- These are safely ignored on Windows - no changes needed
### Editor Configuration
**File:** `cmd/edit_config.go:20-24`
**Issue:** Relies on `$EDITOR` environment variable which is less common on Windows.
**Current behavior:** Fails if `$EDITOR` is not set
**Recommended improvement:** Add fallback detection for Windows editors:
```go
editor := os.Getenv("EDITOR")
if editor == "" {
if runtime.GOOS == "windows" {
editor = "notepad.exe" // or "code.exe" for VS Code
} else {
fmt.Println("Error: $EDITOR environment variable is not set")
return
}
}
```
## Installation Instructions for Windows
1. Install required dependencies:
```powershell
winget install sharkdp.fd
winget install Git.Git
```
2. Fix the path handling bug in `app/env_file.go:209`
3. Build and run as normal:
```powershell
go build
.\envr.exe init
```
## Testing on Windows
After applying the critical path fix, the core functionality should work correctly on Windows. The application has been designed with cross-platform compatibility in mind, using:
- `filepath` package for path operations (mostly)
- `os.UserHomeDir()` for home directory detection
- Standard Go file operations
## Summary
- **1 critical bug** must be fixed for Windows compatibility
- **2 external tools** need to be installed
- **1 minor enhancement** recommended for better Windows UX
- Overall architecture is Windows-compatible
+366 -150
View File
@@ -1,20 +1,58 @@
package main
import "base:runtime"
import "core:bufio"
import "core:fmt"
import "core:io"
import "core:os"
import "core:reflect"
import "core:strings"
import "core:terminal"
import "core:text/table"
Command :: struct {
name: string,
args: [dynamic]string,
flags: map[string]string,
bool_set: map[string]bool,
config_path: string,
out_buf: ^bufio.Writer,
out: io.Writer,
err: io.Writer,
name: string,
args: [dynamic]string,
flags: Flags,
out_buf: ^bufio.Writer,
out: io.Writer,
err: io.Writer,
}
Flags :: struct {
help: bool `args:"short=h" usage:"show this documentation"`,
config_file: string `args:"name=config-file,short=c" usage:"config file" default:"~/.envr/config.json"`,
output: Output_Format `args:"short=o" usage:"the format of output data" default:"table" completion:"output"`,
color: Color_Mode `usage:"Whether or not to colorize output" default:"auto" completion:"color"`,
force: bool `args:"short=f" usage:"Overwrite existing config"`,
}
Flag_Type :: enum {
Help,
Config_File,
Output,
Color,
Force,
}
Output_Format :: enum {
Auto,
Table,
JSON,
}
Color_Mode :: enum {
Auto,
Always,
Never,
}
Positional_Arg :: struct {
name: string,
ntype: string, // nushell type: "path", "string". "" defaults to "path"
completion: string,
optional: bool,
desc: string,
}
CommandInfo :: struct {
@@ -23,50 +61,115 @@ CommandInfo :: struct {
short: string,
long: string,
aliases: []string,
// Flags supported by the command
flags: bit_set[Flag_Type],
args: []Positional_Arg,
}
GLOBAL_FLAGS: bit_set[Flag_Type] = {.Help, .Config_File, .Color}
COMMANDS := []CommandInfo {
{
"init",
"envr init",
"Set up envr",
`The init command generates your initial config and saves it to
name = "init",
usage = "envr init",
short = "Set up envr",
long = `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.`,
{},
flags = GLOBAL_FLAGS + {.Force},
},
{"scan", "envr scan", "Find and select .env files for backup", "", {}},
{"sync", "envr sync", "Update or restore your env backups", "", {}},
{"backup", "envr backup <path>", "Import a .env file into envr", "", {"add"}},
{"restore", "envr restore <path>", "Restore a .env file from the database", "", {}},
{"list", "envr list", "View your tracked files", "", {}},
{"remove", "envr remove <path>", "Remove a .env file from your database", "", {}},
{"check", "envr check [path]", "Check if files are backed up", "", {}},
{
"deps",
"envr deps",
"Check for missing binaries",
"envr relies on external binaries for certain functionality.\n\nThe check command reports on which binaries are available and which are not.",
{},
name = "scan",
usage = "envr scan",
short = "Find and select .env files for backup",
flags = GLOBAL_FLAGS,
},
{"version", "envr version", "Show envr's version", "", {}},
{"edit-config", "envr edit-config", "Edit your config with your default editor", "", {}},
{
"nushell-completion",
"envr nushell-completion",
"Generate custom completions for nushell",
"",
{},
name = "sync",
usage = "envr sync",
short = "Update or restore your env backups",
flags = GLOBAL_FLAGS + {.Output},
},
}
{
name = "backup",
usage = "envr backup <path>",
short = "Import a .env file into envr",
aliases = {"add"},
flags = GLOBAL_FLAGS,
args = {
{name = "path", completion = "untracked-paths", desc = "Path to .env file to backup"},
},
},
{
name = "restore",
usage = "envr restore <path>",
short = "Restore a .env file from the database",
flags = GLOBAL_FLAGS,
args = {
{name = "path", completion = "tracked-paths", desc = "Path to .env file to restore"},
},
},
{
name = "list",
usage = "envr list",
short = "View your tracked files",
flags = GLOBAL_FLAGS + {.Output},
},
{
name = "remove",
usage = "envr remove <path>",
short = "Remove a .env file from your database",
flags = GLOBAL_FLAGS,
args = {
{name = "path", completion = "tracked-paths", desc = "Path to .env file to remove"},
},
},
{
name = "check",
usage = "envr check [path]",
short = "Check if files are backed up",
flags = GLOBAL_FLAGS,
args = {
{
name = "path",
optional = true,
desc = "Path to check (defaults to current directory)",
},
},
},
{name = "version", usage = "envr version", short = "Show envr's version", flags = {.Help}},
{
name = "edit-config",
usage = "envr edit-config",
short = "Edit your config with your default editor",
flags = GLOBAL_FLAGS,
},
{
name = "completion",
usage = "envr completion <shell>",
short = "Generate shell completion scripts",
long = `Supported shells:
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)
nushell
bash`,
flags = {.Help},
args = {
{
name = "shell",
ntype = "string",
completion = "shells",
desc = "Shell to generate completions for",
},
},
},
{
name = "uninstall",
usage = "envr uninstall",
short = "Remove envr configuration and database",
long = "This will remove all files generated by envr from your system.",
flags = GLOBAL_FLAGS + {.Force},
},
}
// Caller is responsible for calling delete_command(cmd).
@@ -74,7 +177,7 @@ delete_command :: proc(cmd: ^Command) {
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)
bufio.writer_init(cmd.out_buf, out, allocator = context.allocator)
cmd.out = bufio.writer_to_writer(cmd.out_buf)
cmd.err = err
}
@@ -85,51 +188,33 @@ parse_args :: proc(args: []string, out: io.Stream, err: io.Stream) -> (cmd: Comm
}
cmd.name = args[1]
cmd.args = make([dynamic]string, 0, len(args[2:]))
cmd.args = make([dynamic]string)
cmd.flags = make(map[string]string)
cmd.bool_set = make(map[string]bool)
i := 2
for i < len(args) {
arg := args[i]
if strings.starts_with(arg, "--") {
key := arg[2:]
if i + 1 < len(args) && !strings.starts_with(args[i + 1], "-") {
cmd.flags[key] = args[i + 1]
i += 2
} else {
cmd.bool_set[key] = true
i += 1
}
} else if strings.starts_with(arg, "-") && len(arg) == 2 {
key_slice := arg[1:2]
if i + 1 < len(args) && !strings.starts_with(args[i + 1], "-") {
cmd.flags[key_slice] = args[i + 1]
i += 2
} else {
cmd.bool_set[key_slice] = true
i += 1
}
} else {
append(&cmd.args, arg)
i += 1
}
overflow := parse_flags(&cmd.flags, args[2:])
for arg in overflow {
append(&cmd.args, arg)
}
if val, ok := cmd.flags["config-file"]; ok {
cmd.config_path = val
} else if val, ok := cmd.flags["c"]; ok {
cmd.config_path = val
} else {
if cmd.flags.output == .Auto {
cmd.flags.output = terminal.is_terminal(os.stdout) ? .Table : .JSON
}
if cmd.flags.color == .Auto {
cmd.flags.color = terminal.is_terminal(os.stdout) ? .Always : .Never
}
if cmd.flags.color == .Never {
disable_color = true
}
if cmd.flags.config_file == "" {
// FIXME: Handle err
// TODO: Is this right?
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)
cmd.flags.config_file = default_config_path(home, context.temp_allocator)
}
if has_flag(&cmd, "help") {
if cmd.flags.help {
print_command_help(&cmd)
return cmd, false
}
@@ -137,13 +222,175 @@ parse_args :: proc(args: []string, out: io.Stream, err: io.Stream) -> (cmd: Comm
return cmd, true
}
has_flag :: proc(cmd: ^Command, name: string) -> bool {
_, ok := cmd.flags[name]
if ok {
return true
print_command_help :: proc(cmd: ^Command) {
ok := write_command_help(cmd.name, cmd.out)
if !ok {
fmt.wprintf(cmd.err, "Unknown command: %s\n", cmd.name)
write_usage(cmd.out)
}
}
write_command_help :: proc(name: string, w: io.Writer) -> bool {
// TODO: rename info to cmd?
info, found := find_command(name)
if !found {
return false
}
fmt.wprintf(
w,
"%s\n\n\n%s\n\n %s [flags]\n\n",
info.short,
colorize(.Heading, "Usage:"),
colorize(.Flag, info.usage),
flush = false,
)
if len(info.aliases) > 0 {
fmt.wprintf(
w,
"\n%s\n\n %s",
colorize(.Heading, "Aliases:"),
colorize(.Command, info.name),
flush = false,
)
for a in info.aliases {
fmt.wprintf(w, ", %s", colorize(.Command, a), flush = false)
}
fmt.wprintf(w, "\n", flush = false)
}
if len(info.long) > 0 {
fmt.wprintf(w, "\n%s\n", info.long, flush = false)
}
tbl: table.Table
table.init(&tbl, context.temp_allocator, context.temp_allocator)
table.padding(&tbl, 2, 0)
write_flags_table(&tbl, info.flags)
fmt.wprintf(w, "\n", flush = false)
write_borderless_table(w, &tbl)
table_reset(&tbl)
return true
}
Flag_Kind :: enum {
Bool,
String,
Enum,
}
Flag_Field :: struct {
long_name: string,
short_name: string,
kind: Flag_Kind,
usage: string,
default_val: string,
enum_values: string,
completion: string,
}
write_flags_table :: proc(tbl: ^table.Table, flags: bit_set[Flag_Type]) {
table.caption(tbl, "Flags:")
for ft in Flag_Type {
if ft not_in flags do continue
names, hint, desc := flag_field_info(ft)
if len(hint) > 0 {
display := table.format(
tbl,
"%s%s",
colorize(.Flag, names, tbl.format_allocator),
hint,
)
table.row(tbl, display, desc)
} else {
table.row(tbl, colorize(.Flag, names, tbl.format_allocator), desc)
}
}
}
flag_field_info :: proc(
ft: Flag_Type,
) -> (
names: string,
value_hint: string,
description: string,
) {
f := flag_field(ft)
if len(f.short_name) > 0 {
names = fmt.tprintf("-%s, --%s", f.short_name, f.long_name)
} else {
names = fmt.tprintf("--%s", f.long_name)
}
switch f.kind {
case .Bool:
value_hint = ""
case .String:
value_hint = " <value>"
case .Enum:
value_hint = fmt.tprintf(" %s", f.enum_values)
}
description = f.usage
if len(f.default_val) > 0 {
switch f.kind {
case .Bool:
// do nothing
case .String:
description = fmt.tprintf(`%s (default "%s")`, f.usage, f.default_val)
case .Enum:
description = fmt.tprintf("%s (default '%s')", f.usage, f.default_val)
}
}
return
}
flag_field :: proc(ft: Flag_Type) -> Flag_Field {
field := reflect.struct_field_at(Flags, int(ft))
args_tag := reflect.struct_tag_get(field.tag, "args")
long_name, _ := strings.replace(field.name, "_", "-", -1, context.temp_allocator)
if n, ok := get_subtag(args_tag, "name"); ok {
long_name = n
}
short, has_short := get_subtag(args_tag, "short")
base_ti := runtime.type_info_base(field.type)
kind: Flag_Kind
enum_values: string
if _, is_bool := base_ti.variant.(runtime.Type_Info_Boolean); is_bool {
kind = .Bool
} else if _, is_string := base_ti.variant.(runtime.Type_Info_String); is_string {
kind = .String
} else if enum_ti, is_enum := base_ti.variant.(runtime.Type_Info_Enum); is_enum {
kind = .Enum
parts := make([dynamic]string, 0, len(enum_ti.names), context.temp_allocator)
for name in enum_ti.names {
lower := strings.to_lower(name, context.temp_allocator)
append(&parts, fmt.tprintf("'%s'", lower))
}
enum_values = strings.join(parts[:], "|", context.temp_allocator)
delete(parts)
}
usage := reflect.struct_tag_get(field.tag, "usage")
default_val := reflect.struct_tag_get(field.tag, "default")
completion := reflect.struct_tag_get(field.tag, "completion")
return {
long_name = long_name,
short_name = has_short ? short : "",
kind = kind,
usage = usage,
default_val = default_val,
enum_values = enum_values,
completion = completion,
}
_, ok2 := cmd.bool_set[name]
return ok2
}
find_command :: proc(name: string) -> (CommandInfo, bool) {
@@ -160,53 +407,11 @@ find_command :: proc(name: string) -> (CommandInfo, bool) {
return CommandInfo{}, false
}
write_command_help :: proc(name: string, w: io.Writer) -> bool {
info, found := find_command(name)
if !found {
return false
}
fmt.wprintf(w, "Usage: %s [flags]\n\n", info.usage, flush = false)
fmt.wprintf(w, "%s\n", info.short, flush = false)
if len(info.aliases) > 0 {
fmt.wprintf(w, "\nAliases:\n %s", info.name, flush = false)
for a in info.aliases {
fmt.wprintf(w, ", %s", a, flush = false)
}
fmt.wprintf(w, "\n", flush = false)
}
if len(info.long) > 0 {
fmt.wprintf(w, "\n%s\n", info.long, flush = false)
}
fmt.wprintf(
w,
"\nFlags:\n -h, --help help for %s\n -c, --config-file <path> config file (default \"~/.envr/config.json\")\n",
info.name,
flush = false,
)
return true
}
print_command_help :: proc(cmd: ^Command) {
ok := write_command_help(cmd.name, cmd.out)
if !ok {
fmt.wprintf(cmd.err, "Unknown command: %s\n", cmd.name)
write_usage(cmd.out)
}
}
// TODO: command args should be shown in usage.
write_usage :: proc(w: io.Writer) {
fmt.wprintf(
w,
`envr keeps your .env synced to a local, age encrypted database.
ENVR_DESCRIPTION :: `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
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:
@@ -231,42 +436,53 @@ Select the files you want to back up from the interactive list.
5. If you lose a repository, after re-cloning the repo into the same path it was
at before, restore your backup with:
> envr restore ~/<path to repository>/.env
> envr restore ~/<path to repository>/.env`
Usage:
envr [command]
Available Commands:
`,
// TODO: command args should be shown in usage.
write_usage :: proc(w: io.Writer) {
fmt.wprintf(
w,
"%s\n\n%s\n\n %s [command]\n\n",
ENVR_DESCRIPTION,
colorize(.Heading, "Usage:"),
colorize(.Flag, "envr"),
flush = false,
)
tbl: table.Table
table.init(&tbl, context.temp_allocator, context.temp_allocator)
table.padding(&tbl, 2, 0)
table.caption(&tbl, "Available Commands:")
for c in COMMANDS {
name_start := len(c.name)
fmt.wprintf(w, "%s", c.name, flush = false)
name := c.name
// TODO: Can we do better?
for a in c.aliases {
fmt.wprintf(w, ", %s", a, flush = false)
name_start += len(a) + 2
name = strings.join([]string{name, a}, ", ", tbl.format_allocator)
}
padding := 20 - name_start
if padding > 0 {
for _ in 0 ..< padding {
io.write_byte(w, ' ')
}
}
fmt.wprintf(w, " %s\n", c.short, flush = false)
table.row(&tbl, colorize(.Command, name, tbl.format_allocator), c.short)
}
write_borderless_table(w, &tbl)
table_reset(&tbl)
write_flags_table(&tbl, GLOBAL_FLAGS)
write_borderless_table(w, &tbl)
table_reset(&tbl)
fmt.wprintf(
w,
`
Flags:
-h, --help help for envr
-c, --config-file <path> config file (default "~/.envr/config.json")
Use "envr [command] --help" for more information about a command.
`,
`Use "%s [command] --help" for more information about a command.`,
colorize(.Flag, "envr", tbl.format_allocator),
flush = false,
)
}
delete_command :: proc(cmd: ^Command) {
bufio.writer_flush(cmd.out_buf)
delete(cmd.args)
bufio.writer_destroy(cmd.out_buf)
free(cmd.out_buf)
}
+84 -110
View File
@@ -1,5 +1,6 @@
#+feature dynamic-literals
#+test
package main
import "core:bufio"
import "core:fmt"
@@ -57,7 +58,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, "--help"), "missing --help flag")
testing.expect(t, strings.contains(text, "[command] --help"), "missing help hint")
}
}
@(test)
test_command_help_backup :: proc(t: ^testing.T) {
@@ -109,7 +110,8 @@ test_command_help_init_no_aliases :: proc(t: ^testing.T) {
testing.expect(t, !strings.contains(text, "Aliases:"), "init should not have Aliases section")
testing.expect(t, strings.contains(text, "Flags:"), "missing Flags section")
testing.expect(t, strings.contains(text, "show this documentation"), "missing help flag description")
}
testing.expect(t, strings.contains(text, "--force"), "missing --force flag")
}
@(test)
test_command_help_unknown :: proc(t: ^testing.T) {
@@ -122,7 +124,7 @@ test_command_help_unknown :: proc(t: ^testing.T) {
text := strings.to_string(b)
testing.expect_value(t, len(text), 0)
}
}
@(test)
test_command_help_version :: proc(t: ^testing.T) {
@@ -143,53 +145,6 @@ test_command_help_version :: proc(t: ^testing.T) {
}
test_parse_args :: proc(
test_has_flag_bool_set :: proc(t: ^testing.T) {
cmd := Command {
name = "test",
bool_set = map[string]bool{"force" = true},
}
defer delete(cmd.bool_set)
testing.expect(t, has_flag(&cmd, "force"), "should find flag in bool_set")
testing.expect(t, !has_flag(&cmd, "verbose"), "should not find missing flag")
}
@(test)
test_has_flag_value_map :: proc(t: ^testing.T) {
cmd := Command {
name = "test",
flags = map[string]string{"output" = "/tmp/out"},
}
defer delete(cmd.flags)
testing.expect(t, has_flag(&cmd, "output"), "should find flag in flags map")
testing.expect(t, !has_flag(&cmd, "force"), "should not find missing flag")
}
@(test)
test_has_flag_both_maps :: proc(t: ^testing.T) {
cmd := Command {
name = "test",
flags = map[string]string{"output" = "/tmp/out"},
bool_set = map[string]bool{"force" = true},
}
defer delete(cmd.flags)
defer delete(cmd.bool_set)
testing.expect(t, has_flag(&cmd, "output"), "should find in flags")
testing.expect(t, has_flag(&cmd, "force"), "should find in bool_set")
testing.expect(t, !has_flag(&cmd, "verbose"), "should not find missing flag")
}
@(test)
test_has_flag_empty_command :: proc(t: ^testing.T) {
cmd := Command {
name = "test",
}
testing.expect(t, !has_flag(&cmd, "anything"), "empty command should have no flags")
}
test_parse_args :: proc(
args: []string,
) -> (
cmd: Command,
@@ -225,8 +180,6 @@ test_parse_args_bare_command :: proc(t: ^testing.T) {
testing.expect_value(t, cmd.name, "list")
testing.expect_value(t, len(cmd.args), 0)
}
testing.expect_value(t, len(cmd.bool_set), 0)
}
@(test)
test_parse_args_positional :: proc(t: ^testing.T) {
@@ -235,49 +188,51 @@ test_parse_args_positional :: proc(t: ^testing.T) {
testing.expect(t, ok, "should succeed")
testing.expect_value(t, cmd.name, "backup")
testing.expect(t, len(cmd.args) == 1)
testing.expect(t, cmd.args[0] == "/project/.env")
}
testing.expect_value(t, len(cmd.args), 1)
testing.expect_value(t, cmd.args[0], "/project/.env")
}
@(test)
test_parse_args_config_file_long_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "sync", "--config", "x.json"})
testing.expect(t, ok, "should succeed")
cmd, ok, _, _ := test_parse_args(
[]string{"envr", "sync", "--config-file", "x.json"},
)
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.config_file, "x.json")
}
}
@(test)
test_parse_args_config_file_short_flag :: 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")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.config_file, "x.json")
}
}
@(test)
test_parse_args_force_long_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")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.force, true)
}
}
@(test)
test_parse_args_force_short_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "version", "-l"})
testing.expect(t, ok, "should succeed")
cmd, ok, _, _ := test_parse_args([]string{"envr", "init", "-f"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.force, true)
}
}
@(test)
test_parse_args_multiple_positionals :: proc(t: ^testing.T) {
@@ -287,9 +242,9 @@ test_parse_args_multiple_positionals :: proc(t: ^testing.T) {
defer delete_command(&cmd)
testing.expect_value(t, len(cmd.args), 2)
testing.expect(t, cmd.args[0] == "a")
testing.expect(t, cmd.args[1] == "b")
}
testing.expect_value(t, cmd.args[0], "a")
testing.expect_value(t, cmd.args[1], "b")
}
@(test)
test_parse_args_mixed_flags_and_positionals :: proc(t: ^testing.T) {
@@ -299,9 +254,9 @@ test_parse_args_mixed_flags_and_positionals :: proc(t: ^testing.T) {
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.force, true)
testing.expect(t, len(cmd.args) == 1)
testing.expect(t, cmd.args[0] == "/project/.env")
}
testing.expect_value(t, len(cmd.args), 1)
testing.expect_value(t, cmd.args[0], "/project/.env")
}
@(test)
test_parse_args_no_args :: proc(t: ^testing.T) {
@@ -313,58 +268,77 @@ test_parse_args_no_args :: proc(t: ^testing.T) {
@(test)
test_parse_args_flag_then_positional_then_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "backup", "--force", "a.env", "--output", "json"})
defer delete_command(&cmd)
defer delete_command(&cmd)
testing.expect(t, ok, "should succeed")
testing.expect_value(t, cmd.flags.force, true)
testing.expect(t, cmd.bool_set["verbose"] == true)
testing.expect(t, len(cmd.args) == 1)
testing.expect(t, cmd.args[0] == "a.env")
}
testing.expect_value(t, cmd.flags.output, Output_Format.JSON)
testing.expect_value(t, len(cmd.args), 1)
testing.expect_value(t, cmd.args[0], "a.env")
}
@(test)
test_parse_args_config_file_default :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args(
[]string{"envr", "list", "--config-file", "/custom/config.json"},
)
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect(
t,
cmd.config_path == "/custom/config.json",
"config_path should be set from --config-file",
)
}
@(test)
test_parse_args_config_file_short_flag :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "-c", "/custom/config.json"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect(
t,
cmd.config_path == "/custom/config.json",
"config_path should be set from -c",
)
}
@(test)
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")
if !ok do return
defer delete_command(&cmd)
testing.expect(t, len(cmd.flags.config_file) > 0, "config_file should default to non-empty path")
testing.expect(
testing.expect(
t,
strings.contains(cmd.flags.config_file, ".envr"),
"default config_path should contain .envr dir, got %s",
)
"default config_file should contain .envr dir, got %s",
)
}
@(test)
test_parse_args_output_long_json :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "--output", "json"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.output, Output_Format.JSON)
}
@(test)
test_parse_args_output_short_json :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "-o", "json"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.output, Output_Format.JSON)
}
@(test)
test_parse_args_output_long_table :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "--output", "table"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.output, Output_Format.Table)
}
@(test)
test_parse_args_output_short_table :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "-o", "table"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.output, Output_Format.Table)
}
@(test)
test_parse_args_output_equals_syntax :: proc(t: ^testing.T) {
cmd, ok, _, _ := test_parse_args([]string{"envr", "list", "--output=json"})
testing.expect(t, ok, "should succeed")
if !ok do return
defer delete_command(&cmd)
testing.expect_value(t, cmd.flags.output, Output_Format.JSON)
}
}
+4 -1
View File
@@ -15,12 +15,15 @@ cmd_backup :: proc(cmd: ^Command) {
return
}
// TODO: allow new_env_file to accept allocator?
// TODO: Write a test that covers this leak
file, ok := new_env_file(path)
defer delete_envfile(&file)
if !ok {
return
}
db, db_ok := db_open(cmd.config_path)
db, db_ok := db_open(cmd.flags.config_file)
if !db_ok {
return
}
+28 -33
View File
@@ -4,61 +4,46 @@ import "core:fmt"
import "core:os"
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) {
feats := check_features()
check_path: string
_check_path: string
if len(cmd.args) > 0 {
check_path = cmd.args[0]
_check_path = cmd.args[0]
} else {
cwd, cwd_err := os.get_working_directory(context.temp_allocator)
if cwd_err != nil {
fmt.wprintf(cmd.err, "Error getting current directory: %v\n", cwd_err, flush = false)
return
}
check_path = cwd
_check_path = cwd
}
check_path, abs_err := filepath.abs(_check_path, context.temp_allocator)
if abs_err != nil {
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return
}
abs_path: string
if filepath.is_abs(check_path) {
abs_path = check_path
} else {
resolved, abs_err := filepath.abs(check_path)
if abs_err != nil {
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return
}
abs_path = resolved
}
db, db_ok := db_open(cmd.config_path)
db, db_ok := db_open(cmd.flags.config_file)
if !db_ok {
return
}
defer db_close(&db)
is_dir := os.is_directory(abs_path)
is_dir := os.is_directory(check_path)
files_in_path: [dynamic]string
// TODO: set a reasonable default
files_in_path := make([dynamic]string, context.temp_allocator)
if is_dir {
if cant_scan(feats) {
fmt.wprintln(
cmd.err,
"Error: please install fd to use the check command (https://github.com/sharkdp/fd)",
flush = false,
)
return
}
scanned, scan_ok := scan_path(abs_path, db.cfg)
scanned, scan_ok := scan_path(check_path, db.cfg)
if !scan_ok {
fmt.wprintln(cmd.err, "Error scanning directory for .env files", flush = false)
return
}
files_in_path = scanned
} else {
append(&files_in_path, abs_path)
append(&files_in_path, check_path)
}
db_files, list_ok := db_list(&db)
@@ -72,13 +57,23 @@ cmd_check :: proc(cmd: ^Command) {
if len(files_in_path) == 0 {
fmt.wprintln(cmd.out, "No .env files found in the specified directory.", flush = false)
} else {
fmt.wprintln(cmd.out, "✓ All .env files in the directory are backed up.", flush = false)
fmt.wprintln(
cmd.out,
"✓ All .env files in the directory are backed up.",
flush = false,
)
}
} else {
fmt.wprintf(cmd.out, "Found %d .env file(s) that are not backed up:\n", len(not_backed), flush = false)
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 {
fmt.wprintf(cmd.out, " %s\n", file, flush = false)
}
fmt.wprintln(cmd.out, "\nRun 'envr sync' to back up these files.", flush = false)
}
}
+9 -13
View File
@@ -1,40 +1,36 @@
#+test
package main
import "core:fmt"
import "core:testing"
@(test)
test_find_unbacked_finds_missing :: proc(t: ^testing.T) {
local := []string{"/a/.env", "/b/.env", "/c/.env"}
db := []EnvFile{{Path = "/a/.env"}, {Path = "/b/.env"}}
db := []EnvFile{{path = "/a/.env"}, {path = "/b/.env"}}
result := find_unbacked(local, db[:])
testing.expect(t, len(result) == 1, fmt.tprintf("expected 1 unbacked, got %d", len(result)))
testing.expect_value(t, len(result), 1)
if len(result) > 0 {
testing.expect(
t,
result[0] == "/c/.env",
fmt.tprintf("expected /c/.env, got %s", result[0]),
)
testing.expect_value(t, result[0], "/c/.env")
}
}
@(test)
test_find_unbacked_all_backed :: proc(t: ^testing.T) {
local := []string{"/a/.env", "/b/.env"}
db := []EnvFile{{Path = "/a/.env"}, {Path = "/b/.env"}}
db := []EnvFile{{path = "/a/.env"}, {path = "/b/.env"}}
result := find_unbacked(local, db[:])
testing.expect(t, len(result) == 0, fmt.tprintf("expected 0 unbacked, got %d", len(result)))
testing.expect_value(t, len(result), 0)
}
@(test)
test_find_unbacked_no_local :: proc(t: ^testing.T) {
local: []string
db := []EnvFile{{Path = "/a/.env"}}
db := []EnvFile{{path = "/a/.env"}}
result := find_unbacked(local, db[:])
testing.expect(t, len(result) == 0, fmt.tprintf("expected 0 unbacked, got %d", len(result)))
testing.expect_value(t, len(result), 0)
}
@(test)
@@ -43,6 +39,6 @@ test_find_unbacked_none_backed :: proc(t: ^testing.T) {
db: []EnvFile
result := find_unbacked(local, db[:])
testing.expect(t, len(result) == 2, fmt.tprintf("expected 2 unbacked, got %d", len(result)))
testing.expect_value(t, len(result), 2)
}
+268
View File
@@ -0,0 +1,268 @@
package main
import "core:fmt"
import "core:strings"
nushell_header :: `def tracked-paths [] {
(
^envr list
| from json
| each {
[$in.directory $in.path] | path join
}
)
}
def untracked-paths [] {
(
^envr scan
| from json
)
}
def color [] {
['auto' 'always' 'never']
}
def output [] {
['auto' 'table' 'json']
}
def shells [] {
['nushell' 'bash']
}
`
cmd_completion :: proc(cmd: ^Command) {
if len(cmd.args) == 0 {
print_command_help(cmd)
return
}
switch cmd.args[0] {
case "nushell":
fmt.wprint(cmd.out, generate_nushell_completion(), flush = true)
case "bash":
fmt.wprint(cmd.out, generate_bash_completion(), flush = true)
case:
fmt.wprintf(cmd.err, "Unsupported shell: %s\n", cmd.args[0])
fmt.wprintln(cmd.err, "Supported shells: nushell, bash")
}
}
generate_nushell_completion :: proc() -> string {
sb: strings.Builder
strings.builder_init(&sb, context.temp_allocator)
defer strings.builder_destroy(&sb)
fmt.sbprint(&sb, nushell_header)
for c in COMMANDS {
fmt.sbprintf(&sb, "# %s\n", c.short)
fmt.sbprintf(&sb, "export extern \"envr %s\" [\n", c.name)
for ft in Flag_Type {
if ft not_in c.flags do continue
f := flag_field(ft)
fmt.sbprintf(&sb, " %s\n", nushell_flag_line(f))
}
for arg in c.args {
fmt.sbprintf(&sb, " %s\n", nushell_positional_line(arg))
}
fmt.sbprintf(&sb, "]\n")
for a in c.aliases {
fmt.sbprintf(&sb, "\nexport alias \"envr %s\" = envr %s\n", a, c.name)
}
fmt.sbprintf(&sb, "\n")
}
return strings.to_string(sb)
}
nushell_flag_line :: proc(f: Flag_Field) -> string {
line: string
if len(f.short_name) > 0 {
line = fmt.tprintf("--%s(-%s)", f.long_name, f.short_name)
} else {
line = fmt.tprintf("--%s", f.long_name)
}
switch f.kind {
case .Bool:
case .String:
line = fmt.tprintf("%s: path", line)
case .Enum:
if len(f.completion) > 0 {
line = fmt.tprintf("%s: string@%s", line, f.completion)
} else {
line = fmt.tprintf("%s: string", line)
}
}
return fmt.tprintf("%s # %s", line, f.usage)
}
nushell_positional_line :: proc(arg: Positional_Arg) -> string {
name := arg.name
if arg.optional {
name = fmt.tprintf("%s?", name)
}
ntype := len(arg.ntype) > 0 ? arg.ntype : "path"
if len(arg.completion) > 0 {
return fmt.tprintf("%s: %s@%s", name, ntype, arg.completion)
}
return fmt.tprintf("%s: %s", name, ntype)
}
bash_header :: `
_envr() {
local cur prev cmd
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
cmd="${COMP_WORDS[1]}"
`
bash_footer :: `
}
complete -F _envr envr
`
generate_bash_completion :: proc() -> string {
sb: strings.Builder
strings.builder_init(&sb, context.temp_allocator)
defer strings.builder_destroy(&sb)
fmt.sbprint(&sb, bash_header)
// B: Subcommand completion
fmt.sbprintf(&sb, " if [[ $COMP_CWORD -eq 1 ]]; then\n")
fmt.sbprintf(&sb, " COMPREPLY=( $(compgen -W \"")
first := true
for c in COMMANDS {
if !first do fmt.sbprintf(&sb, " ")
fmt.sbprintf(&sb, "%s", c.name)
first = false
for a in c.aliases {
fmt.sbprintf(&sb, " %s", a)
}
}
fmt.sbprintf(&sb, "\" -- \"$cur\") )\n")
fmt.sbprintf(&sb, " return\n")
fmt.sbprintf(&sb, " fi\n\n")
// C: Flag value completion
fmt.sbprintf(&sb, " case \"$prev\" in\n")
for ft in Flag_Type {
f := flag_field(ft)
if f.kind == .Bool do continue
pattern := fmt.tprintf("--%s", f.long_name)
if len(f.short_name) > 0 {
pattern = fmt.tprintf("%s|-%s", pattern, f.short_name)
}
fmt.sbprintf(&sb, " %s)\n", pattern)
switch f.kind {
case .Enum:
fmt.sbprintf(
&sb,
" COMPREPLY=( $(compgen -W \"%s\" -- \"$cur\") )\n",
bash_enum_values(f.enum_values),
)
case .String:
fmt.sbprintf(&sb, " COMPREPLY=( $(compgen -f -- \"$cur\") )\n")
case .Bool:
panic("unexpected")
}
fmt.sbprintf(&sb, " return\n")
fmt.sbprintf(&sb, " ;;\n")
}
fmt.sbprintf(&sb, " esac\n\n")
// D: Flag name completion per command
fmt.sbprintf(&sb, " case \"$cur\" in -*)\n")
fmt.sbprintf(&sb, " case \"$cmd\" in\n")
for c in COMMANDS {
if c.flags == {} do continue
cmd_pattern := c.name
for a in c.aliases {
cmd_pattern = fmt.tprintf("%s|%s", cmd_pattern, a)
}
fmt.sbprintf(&sb, " %s)\n", cmd_pattern)
fmt.sbprintf(&sb, " COMPREPLY=( $(compgen -W \"")
flag_first := true
for ft in Flag_Type {
if ft not_in c.flags do continue
f := flag_field(ft)
if !flag_first do fmt.sbprintf(&sb, " ")
fmt.sbprintf(&sb, "--%s", f.long_name)
if len(f.short_name) > 0 {
fmt.sbprintf(&sb, " -%s", f.short_name)
}
flag_first = false
}
fmt.sbprintf(&sb, "\" -- \"$cur\") )\n")
fmt.sbprintf(&sb, " return\n")
fmt.sbprintf(&sb, " ;;\n")
}
fmt.sbprintf(&sb, " esac\n")
fmt.sbprintf(&sb, " ;;\n")
fmt.sbprintf(&sb, " esac\n\n")
// E: Positional completion
fmt.sbprintf(&sb, " case \"$cmd\" in\n")
for c in COMMANDS {
has_comp := false
for arg in c.args {
if len(arg.completion) > 0 {
has_comp = true
break
}
}
if !has_comp do continue
cmd_pattern := c.name
for a in c.aliases {
cmd_pattern = fmt.tprintf("%s|%s", cmd_pattern, a)
}
fmt.sbprintf(&sb, " %s)\n", cmd_pattern)
for arg in c.args {
if len(arg.completion) == 0 do continue
comp: string
switch arg.completion {
case "tracked-paths":
comp = "$(envr list --output json 2>/dev/null)"
case "untracked-paths":
comp = "$(envr scan --output json 2>/dev/null)"
case "shells":
comp = "nushell bash"
case:
continue
}
fmt.sbprintf(&sb, " COMPREPLY=( $(compgen -W \"%s\" -- \"$cur\") )\n", comp)
}
fmt.sbprintf(&sb, " return\n")
fmt.sbprintf(&sb, " ;;\n")
}
fmt.sbprintf(&sb, " esac\n")
fmt.sbprint(&sb, bash_footer)
return strings.to_string(sb)
}
bash_enum_values :: proc(enum_values: string) -> string {
s, _ := strings.replace(enum_values, "'", "", -1, context.temp_allocator)
s, _ = strings.replace(s, "|", " ", -1, context.temp_allocator)
return s
}
+133
View File
@@ -0,0 +1,133 @@
#+test
package main
import "core:fmt"
import "core:strings"
import "core:testing"
@(test)
test_nushell_completion_nonempty :: proc(t: ^testing.T) {
script := generate_nushell_completion()
testing.expect(t, len(script) > 0, "completion script should not be empty")
}
@(test)
test_nushell_completion_contains_commands :: proc(t: ^testing.T) {
script := generate_nushell_completion()
expected := []string{
"tracked-paths",
"untracked-paths",
"envr backup",
"envr check",
"envr edit-config",
"envr init",
"envr list",
"envr remove",
"envr restore",
"envr scan",
"envr sync",
"envr version",
"envr completion",
}
for ext in expected {
testing.expect(
t,
strings.contains(script, ext),
fmt.tprintf("expected script to contain %q", ext),
)
}
}
@(test)
test_nushell_completion_contains_flags :: proc(t: ^testing.T) {
script := generate_nushell_completion()
expected_flags := []string{
"--help(-h)",
"--config-file(-c)",
"--color",
"--force(-f)",
"--output(-o)",
}
for flag in expected_flags {
testing.expect(
t,
strings.contains(script, flag),
fmt.tprintf("expected script to contain %q", flag),
)
}
}
@(test)
test_nushell_completion_contains_aliases :: proc(t: ^testing.T) {
script := generate_nushell_completion()
testing.expect(
t,
strings.contains(script, "envr add"),
"expected script to contain 'envr add' alias",
)
}
@(test)
test_nushell_completion_no_help_command :: proc(t: ^testing.T) {
script := generate_nushell_completion()
testing.expect(
t,
!strings.contains(script, "envr help"),
"script should not contain 'envr help' (not a real command)",
)
}
@(test)
test_bash_completion_nonempty :: proc(t: ^testing.T) {
script := generate_bash_completion()
testing.expect(t, len(script) > 0, "bash completion script should not be empty")
}
@(test)
test_bash_completion_contains_registration :: proc(t: ^testing.T) {
script := generate_bash_completion()
testing.expect(
t,
strings.contains(script, "complete -F _envr envr"),
"expected bash script to register completion function",
)
}
@(test)
test_bash_completion_contains_commands :: proc(t: ^testing.T) {
script := generate_bash_completion()
expected := []string{
"init", "scan", "sync", "backup", "add",
"restore", "list", "remove", "check",
"version", "edit-config", "completion",
}
for cmd in expected {
testing.expect(
t,
strings.contains(script, cmd),
fmt.tprintf("expected bash script to contain %q", cmd),
)
}
}
@(test)
test_bash_completion_contains_flags :: proc(t: ^testing.T) {
script := generate_bash_completion()
expected := []string{
"--help", "--config-file", "--color", "--force", "--output",
}
for flag in expected {
testing.expect(
t,
strings.contains(script, flag),
fmt.tprintf("expected bash script to contain %q", flag),
)
}
}
@(test)
test_bash_completion_contains_enum_values :: proc(t: ^testing.T) {
script := generate_bash_completion()
testing.expect(t, strings.contains(script, "auto table json"), "missing output enum values")
testing.expect(t, strings.contains(script, "auto always never"), "missing color enum values")
}
-33
View File
@@ -1,33 +0,0 @@
package main
import "core:fmt"
import "core:os"
import "core:terminal"
// TODO: Improve table rendering
cmd_deps :: proc(cmd: ^Command) {
feats := check_features()
headers := []string{"Feature", "Status"}
rows: [dynamic][]string
if .Git in feats {
append(&rows, []string{"Git", "\u2713 Available"})
} else {
append(&rows, []string{"Git", "\u2717 Missing"})
}
if .Fd in feats {
append(&rows, []string{"fd", "\u2713 Available"})
} else {
append(&rows, []string{"fd", "\u2717 Missing"})
}
if terminal.is_terminal(os.stdout) {
render_table(cmd.out, headers, rows[:])
} else {
render_json_rows(cmd.out, headers, rows[:])
fmt.wprint(cmd.out, "\n", flush = false)
}
}
+4 -3
View File
@@ -10,10 +10,9 @@ cmd_edit_config :: proc(cmd: ^Command) {
return
}
config_path := cmd.config_path
config_path := cmd.flags.config_file
_, stat_err := os.stat(config_path, context.allocator)
if stat_err != nil {
if !os.exists(config_path) {
fmt.wprintf(
cmd.err,
"Config file does not exist at %s. Run 'envr init' first.\n",
@@ -42,6 +41,8 @@ cmd_edit_config :: proc(cmd: ^Command) {
fmt.wprintf(cmd.err, "Error waiting for editor: %v\n", wait_err, flush = false)
return
}
// TODO: Should we call exit inside of commands?
if state.exit_code != 0 {
os.exit(int(state.exit_code))
}
+13 -7
View File
@@ -3,11 +3,12 @@ package main
import "core:fmt"
cmd_init :: proc(cmd: ^Command) {
force := has_flag(cmd, "force") || has_flag(cmd, "f")
force := cmd.flags.force
config_file := cmd.flags.config_file
fmt.wprintln(cmd.out, cmd.config_path, flush = false)
fmt.wprintln(cmd.out, cmd.flags.config_file, flush = false)
_, cfg_exists := load_config(cmd.config_path)
_, cfg_exists := load_config(config_file)
if cfg_exists && !force {
fmt.wprintln(
cmd.out,
@@ -24,15 +25,19 @@ Run again with the --force flag if you want to reinitialize.`,
}
if len(keys) == 0 {
fmt.wprintln(cmd.err, `No ssh-ed25519 keys found in ~/.ssh
Generate one with: ssh-keygen -t ed25519`, flush = false)
fmt.wprintln(
cmd.err,
`No ssh-ed25519 keys found in ~/.ssh
Generate one with: ssh-keygen -t ed25519`,
flush = false,
)
return
}
selected, result := multi_select("Select SSH private keys:", keys[:])
defer delete(selected)
if result == .Cancel {
fmt.wprintln(cmd.out, "\x1b[2mCancelled.\x1b[0m", flush = false)
fmt.wprintln(cmd.out, colorize(.Message, "Cancelled."), flush = false)
return
}
@@ -48,7 +53,7 @@ Generate one with: ssh-keygen -t ed25519`, flush = false)
return
}
cfg := new_config(selected_paths[:], cmd.config_path)
cfg := new_config(selected_paths[:], config_file)
if !save_config(cfg, force = force) {
return
}
@@ -60,3 +65,4 @@ Generate one with: ssh-keygen -t ed25519`, flush = false)
flush = false,
)
}
+30 -20
View File
@@ -5,17 +5,16 @@ import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:strings"
import "core:terminal"
import "core:text/table"
ListEntry :: struct {
Directory: string `json:"directory"`,
Path: string `json:"path"`,
dir: string `json:"directory"`,
path: string `json:"path"`,
}
// TODO: Support --format flag
// TODO: Improve table rendering
cmd_list :: proc(cmd: ^Command) {
db, db_ok := db_open(cmd.config_path)
db, db_ok := db_open(cmd.flags.config_file)
if !db_ok {
return
}
@@ -25,36 +24,47 @@ cmd_list :: proc(cmd: ^Command) {
if !list_ok {
return
}
defer delete(rows)
if terminal.is_terminal(os.stdout) {
headers := []string{"Directory", "Path"}
table_rows := make([dynamic][]string, 0, len(rows), context.temp_allocator)
if cmd.flags.output == .Table {
t: table.Table
table.init(&t, context.temp_allocator, context.temp_allocator)
table.padding(&t, 1, 1)
table.aligned_header_of_values(
&t,
.Center,
colorize(.Table_Heading, "Directory"),
colorize(.Table_Heading, "Path"),
)
for row in rows {
dir_str := strings.concatenate({row.Dir, "/"}, context.temp_allocator)
filename := filepath.base(row.Path)
row_slice := make([]string, 2)
row_slice[0] = dir_str
row_slice[1] = filename
append(&table_rows, row_slice)
dir_str := strings.concatenate(
{row.dir, os.Path_Separator_String},
context.temp_allocator,
)
filename := filepath.base(row.path)
table.row(&t, dir_str, filename)
}
render_table(cmd.out, headers, table_rows[:])
table.write_decorated_table(cmd.out, &t, decorations, ansi_aware_width)
} else {
// TODO: Should we instead print full entries here?
entries: [dynamic]ListEntry
entries := make([dynamic]ListEntry, 0, len(rows), context.temp_allocator)
for row in rows {
filename := filepath.base(row.Path)
filename := filepath.base(row.path)
append(
&entries,
ListEntry {
Directory = strings.concatenate({row.Dir, "/"}, context.temp_allocator),
Path = filename,
dir = strings.concatenate(
{row.dir, os.Path_Separator_String},
context.temp_allocator,
),
path = filename,
},
)
}
data, marshal_err := json.marshal(entries[:], allocator = context.temp_allocator)
if marshal_err != nil {
fmt.wprintf(cmd.err, "Error marshaling JSON: %v\n", marshal_err, flush = false)
+99 -2
View File
@@ -1,6 +1,11 @@
#+feature dynamic-literals
#+test
package main
import "core:bufio"
import "core:os"
import "core:path/filepath"
import "core:strings"
import "core:testing"
@(test)
@@ -10,9 +15,101 @@ test_filepath_base_equals_rel :: proc(t: ^testing.T) {
for path in cases {
dir := filepath.dir(path)
rel, rel_err := filepath.rel(dir, path, context.temp_allocator)
testing.expect(t, rel_err == nil, "filepath.rel returned an error")
testing.expect_value(t, rel_err, nil)
base := filepath.base(path)
testing.expect(t, rel == base, "filepath.rel(dir, path) should equal filepath.base(path)")
testing.expect_value(t, rel, base)
}
}
@(test)
test_cmd_list_output_json :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-list-json-*")
defer os.remove_all(base)
cfg_path, _ := filepath.join([]string{base, "config.json"}, context.temp_allocator)
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
testing.expect(t, save_config(cfg, force = true), "save should succeed")
delete_config(&cfg)
db, db_ok := db_open(cfg_path)
testing.expect(t, db_ok, "db should open")
if !db_ok do return
f := make_test_env_file("/project/.env", "abc123", "SECRET=value")
defer delete(f.remotes)
testing.expect(t, db_insert(&db, f), "insert should succeed")
db_close(&db)
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(
[]string{"envr", "list", "--output", "json", "--config-file", cfg_path},
strings.to_stream(&out_b),
strings.to_stream(&err_b),
)
testing.expect(t, ok, "parse_args should succeed")
if !ok do return
defer delete_command(&cmd)
cmd_list(&cmd)
bufio.writer_flush(cmd.out_buf)
output := strings.to_string(out_b)
testing.expect(t, strings.contains(output, "["), "json output should contain '['")
testing.expect(
t,
strings.contains(output, "\"directory\""),
"json output should contain directory key",
)
}
@(test)
test_cmd_list_output_table :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-list-table-*")
defer os.remove_all(base)
cfg_path, _ := filepath.join([]string{base, "config.json"}, context.temp_allocator)
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
testing.expect(t, save_config(cfg, force = true), "save should succeed")
delete_config(&cfg)
db, db_ok := db_open(cfg_path)
testing.expect(t, db_ok, "db should open")
if !db_ok do return
f := make_test_env_file("/project/.env", "abc123", "SECRET=value")
defer delete(f.remotes)
testing.expect(t, db_insert(&db, f), "insert should succeed")
db_close(&db)
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(
[]string{"envr", "list", "--output", "table", "--config-file", cfg_path},
strings.to_stream(&out_b),
strings.to_stream(&err_b),
)
testing.expect(t, ok, "parse_args should succeed")
if !ok do return
defer delete_command(&cmd)
cmd_list(&cmd)
bufio.writer_flush(cmd.out_buf)
output := strings.to_string(out_b)
testing.expect(t, strings.contains(output, "│"), "table output should contain border chars")
testing.expect(
t,
strings.contains(output, "Directory"),
"table output should contain Directory header",
)
}
-10
View File
@@ -1,10 +0,0 @@
package main
import "core:fmt"
COMPLETION_SCRIPT: string : string(#load("mod.nu"))
cmd_nushell_completion :: proc(cmd: ^Command) {
fmt.wprint(cmd.out, COMPLETION_SCRIPT, flush = false)
}
-36
View File
@@ -1,36 +0,0 @@
package main
import "core:fmt"
import "core:strings"
import "core:testing"
@(test)
test_nushell_completion_nonempty :: proc(t: ^testing.T) {
testing.expect(t, len(COMPLETION_SCRIPT) > 0, "completion script should not be empty")
}
@(test)
test_nushell_completion_contains_externs :: proc(t: ^testing.T) {
expected := []string{
"tracked-paths",
"untracked-paths",
"envr backup",
"envr check",
"envr edit-config",
"envr help",
"envr init",
"envr list",
"envr remove",
"envr restore",
"envr scan",
"envr sync",
"envr nushell-completion",
}
for ext in expected {
testing.expect(
t,
strings.contains(COMPLETION_SCRIPT, ext),
fmt.tprintf("expected script to contain %q", ext),
)
}
}
+5 -12
View File
@@ -16,20 +16,13 @@ cmd_remove :: proc(cmd: ^Command) {
return
}
// TODO: Is this the best way to do it?
abs_path: string
if filepath.is_abs(path) {
abs_path = path
} else {
resolved, abs_err := filepath.abs(path)
if abs_err != nil {
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return
}
abs_path = resolved
abs_path, abs_err := filepath.abs(path, context.temp_allocator)
if abs_err != nil {
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return
}
db, db_ok := db_open(cmd.config_path)
db, db_ok := db_open(cmd.flags.config_file)
if !db_ok {
return
}
+16 -18
View File
@@ -16,21 +16,14 @@ cmd_restore :: proc(cmd: ^Command) {
fmt.wprintln(cmd.err, "Error: No path provided", flush = false)
return
}
abs_path, abs_err := filepath.abs(path, context.temp_allocator)
if abs_err != nil {
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
// TODO: Is this the right way to handle this?
abs_path: string
if filepath.is_abs(path) {
abs_path = path
} else {
resolved, abs_err := filepath.abs(path)
if abs_err != nil {
fmt.wprintf(cmd.err, "Error getting absolute path: %v\n", abs_err, flush = false)
return
}
abs_path = resolved
return
}
db, db_ok := db_open(cmd.config_path)
db, db_ok := db_open(cmd.flags.config_file)
if !db_ok {
return
}
@@ -41,15 +34,20 @@ cmd_restore :: proc(cmd: ^Command) {
return
}
dir := filepath.dir(file.Path)
os.mkdir_all(dir)
dir := filepath.dir(file.path)
if err := os.mkdir_all(dir); err != nil {
fmt.wprintf(cmd.err, "Failed to create directory: %v\n", err, flush = false)
write_err := os.write_entire_file(file.Path, file.contents)
if write_err != nil {
fmt.wprintf(cmd.err, "Error writing file: %v\n", write_err, flush = false)
return
}
fmt.wprintf(cmd.out, "Restored %s\n", file.Path, flush = false)
write_err := os.write_entire_file(file.path, file.contents)
if write_err != nil {
fmt.wprintf(cmd.err, "Error writing file: %v\n", write_err, flush = false)
return
}
fmt.wprintf(cmd.out, "Restored %s\n", file.path, flush = false)
}
+18 -18
View File
@@ -6,23 +6,13 @@ import "core:os"
import "core:terminal"
cmd_scan :: proc(cmd: ^Command) {
feats := check_features()
if cant_scan(feats) {
fmt.wprintln(
cmd.err,
"Error: please install fd to use the scan command (https://github.com/sharkdp/fd)",
flush = false,
)
return
}
db, db_ok := db_open(cmd.config_path)
db, db_ok := db_open(cmd.flags.config_file)
if !db_ok {
return
}
defer db_close(&db)
search_dirs := search_paths(db.cfg)
search_dirs := search_paths(db.cfg, context.temp_allocator)
if len(search_dirs) == 0 {
fmt.wprintln(
cmd.err,
@@ -33,9 +23,15 @@ cmd_scan :: proc(cmd: ^Command) {
}
// 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 {
found, scan_ok := scan_path(dir, db.cfg)
defer delete(found)
if !scan_ok {
fmt.wprintf(cmd.err, "Error scanning %s\n", dir, flush = false)
continue
@@ -75,7 +71,7 @@ cmd_scan :: proc(cmd: ^Command) {
selected, result := multi_select("Select .env files to backup:", files[:])
defer delete(selected)
if result == .Cancel {
fmt.wprintln(cmd.out, "\x1b[2mCancelled.\x1b[0m", flush = false)
fmt.wprintln(cmd.out, colorize(.Message, "Cancelled."), flush = false)
return
}
@@ -84,7 +80,9 @@ cmd_scan :: proc(cmd: ^Command) {
if !selected[i] {
continue
}
// TODO: Test cover this leak
env_file, ok := new_env_file(files[i])
defer delete_envfile(&env_file)
if !ok {
fmt.wprintf(cmd.err, "Error reading %s\n", files[i], flush = false)
continue
@@ -97,14 +95,16 @@ cmd_scan :: proc(cmd: ^Command) {
}
if added_count > 0 {
fmt.wprintf(
fmt.wprintln(
cmd.out,
"\x1b[1;32mSuccessfully added %d file(s) to backup.\x1b[0m\n",
added_count,
colorize(
.Sucess,
fmt.tprintf("Successfully added %d file(s) to backup.", added_count),
),
flush = false,
)
} else {
fmt.wprintln(cmd.out, "\x1b[2mNo files were added.\x1b[0m", flush = false)
fmt.wprintln(cmd.out, colorize(.Message, "No files were added."), flush = false)
}
}
+52 -53
View File
@@ -2,19 +2,16 @@ package main
import "core:encoding/json"
import "core:fmt"
import "core:os"
import "core:strings"
import "core:terminal"
import "core:text/table"
SyncEntry :: struct {
Path: string `json:"path"`,
Status: string `json:"status"`,
path: string `json:"path"`,
status: string `json:"status"`,
}
// TODO: Check for quiet failures.
// TODO: Support --format -f flags
cmd_sync :: proc(cmd: ^Command) {
db, db_ok := db_open(cmd.config_path)
db, db_ok := db_open(cmd.flags.config_file)
if !db_ok {
return
}
@@ -24,68 +21,50 @@ cmd_sync :: proc(cmd: ^Command) {
if !list_ok {
return
}
defer delete(files)
// TODO: Set sane default size
results: [dynamic]SyncEntry
defer delete(results)
results := make([]SyncEntry, len(files), context.temp_allocator)
for &file in files {
old_path: string
old_path, _ = strings.clone(file.Path, context.temp_allocator)
result, err_msg := db_sync(&db, &file)
for &file, i in files {
result, err := db_sync(&db, &file)
status: string
is_dir_updated := .DirUpdated in result
switch {
case .Error in result:
if len(err_msg) > 0 {
status = err_msg
} else {
status = "error"
}
case .BackedUp in result:
status = "Backed Up"
case .Restored in result:
status = "Restored"
case .DirUpdated in result:
if err != .None {
status = sync_error_message(err)
} else if .BackedUp in result {
status = .DirUpdated in result ? "Moved & Backed Up" : "Backed Up"
} else if .Restored in result {
status = .DirUpdated in result ? "Moved & Restored" : "Restored"
} else if .DirUpdated in result {
status = "Moved"
case:
} else {
status = "OK"
}
if is_dir_updated {
if !db_delete(&db, old_path) {
return
}
results[i] = SyncEntry {
path = file.path,
status = status,
}
if db_update_required(result) {
if !db_insert(&db, file) {
return
}
}
path_str, _ := strings.clone(file.Path)
status_str, _ := strings.clone(status)
append(&results, SyncEntry{Path = path_str, Status = status_str})
}
if terminal.is_terminal(os.stdout) {
headers := []string{"File", "Status"}
table_rows := make([dynamic][]string, 0, len(results))
if cmd.flags.output == .Table {
t: table.Table
table.init(&t, context.temp_allocator, context.temp_allocator)
table.padding(&t, 1, 1)
table.aligned_header_of_values(
&t,
.Center,
colorize(.Table_Heading, "File"),
colorize(.Table_Heading, "Status"),
)
for res in results {
row_slice := make([]string, 2)
row_slice[0] = res.Path
row_slice[1] = res.Status
append(&table_rows, row_slice)
table.row(&t, res.path, res.status)
}
render_table(cmd.out, headers, table_rows[:])
table.write_decorated_table(cmd.out, &t, decorations, ansi_aware_width)
} else {
data, marshal_err := json.marshal(results[:])
data, marshal_err := json.marshal(results[:], allocator = context.temp_allocator)
if marshal_err != nil {
fmt.wprintf(cmd.err, "Error marshaling JSON: %v\n", marshal_err, flush = false)
return
@@ -94,3 +73,23 @@ cmd_sync :: proc(cmd: ^Command) {
}
}
sync_error_message :: proc(e: SyncError) -> string {
switch e {
case .None:
return ""
case .DirMissing:
return "directory missing"
case .MultipleDirs:
return "multiple directories found"
case .GitRootFailed:
return "failed to find git roots"
case .WriteFailed:
return "failed to write file"
case .ReadFailed:
return "failed to read file"
case .DbFailed:
return "failed to update database"
}
return "unknown error"
}
+64
View File
@@ -0,0 +1,64 @@
package main
import "core:bufio"
import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:strings"
import "core:terminal"
cmd_uninstall :: proc(cmd: ^Command) {
envr_d := envr_dir(cmd.flags.config_file)
if !os.exists(envr_d) {
fmt.wprintln(cmd.out, "Nothing to remove.", flush = false)
return
}
entries, err := os.read_all_directory_by_path(envr_d, context.temp_allocator)
if err != nil {
fmt.wprintf(cmd.err, "Error reading %s: %v\n", envr_d, err, flush = false)
return
}
paths := make([]string, len(entries), context.temp_allocator)
for entry, i in entries {
paths[i], _ = filepath.join([]string{envr_d, entry.name}, context.temp_allocator)
}
fmt.wprintln(cmd.out, "This will remove:", flush = false)
for path in paths {
fmt.wprintf(cmd.out, " %s\n", path, flush = false)
}
fmt.wprintf(cmd.out, " %s\n", envr_d, flush = false)
if !cmd.flags.force {
if !terminal.is_terminal(os.stdin) {
fmt.wprintln(cmd.err, "Run with --force to skip confirmation.", flush = false)
return
}
fmt.wprintf(cmd.out, "\nAre you sure? [y/N] ", flush = false)
bufio.writer_flush(cmd.out_buf)
buf: [128]u8
n, _ := os.read(os.stdin, buf[:])
response := strings.trim_space(string(buf[:n]))
if response != "y" && response != "Y" {
fmt.wprintln(cmd.out, "Aborted.", flush = false)
return
}
}
remove_err := os.remove_all(envr_d)
if remove_err != nil {
fmt.wprintf(cmd.err, "Error removing %s: %v\n", envr_d, remove_err, flush = false)
return
}
for path in paths {
fmt.wprintf(cmd.out, "Removed %s\n", path, flush = false)
}
fmt.wprintf(cmd.out, "Removed %s\n", envr_d, flush = false)
}
+97
View File
@@ -0,0 +1,97 @@
#+test
package main
import "core:bufio"
import "core:os"
import "core:path/filepath"
import "core:strings"
import "core:testing"
@(test)
test_cmd_uninstall_force :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-uninstall-*")
cfg_path, _ := filepath.join([]string{base, ".envr", "config.json"}, context.temp_allocator)
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
testing.expect(t, save_config(cfg, force = true), "save should succeed")
delete_config(&cfg)
db, db_ok := db_open(cfg_path)
testing.expect(t, db_ok, "db should open")
if !db_ok do return
f := make_test_env_file("/project/.env", "abc123", "SECRET=value")
defer delete(f.remotes)
db_insert(&db, f)
db_close(&db)
envr_d := filepath.dir(cfg_path)
testing.expect(t, os.exists(envr_d), ".envr directory should exist before uninstall")
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(
[]string{"envr", "uninstall", "--force", "--config-file", cfg_path},
strings.to_stream(&out_b),
strings.to_stream(&err_b),
)
testing.expect(t, ok, "parse_args should succeed")
if !ok do return
defer delete_command(&cmd)
cmd_uninstall(&cmd)
bufio.writer_flush(cmd.out_buf)
testing.expect(t, !os.exists(envr_d), ".envr directory should be removed")
output := strings.to_string(out_b)
testing.expect(t, strings.contains(output, "Removed"), "output should list removed files")
testing.expect(
t,
strings.contains(output, "config.json"),
"output should mention config.json",
)
testing.expect(
t,
strings.contains(output, "data.envr"),
"output should mention data.envr",
)
}
@(test)
test_cmd_uninstall_nothing_to_remove :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-uninstall-empty-*")
defer os.remove_all(base)
cfg_path, _ := filepath.join([]string{base, ".envr", "config.json"}, context.temp_allocator)
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(
[]string{"envr", "uninstall", "--force", "--config-file", cfg_path},
strings.to_stream(&out_b),
strings.to_stream(&err_b),
)
testing.expect(t, ok, "parse_args should succeed")
if !ok do return
defer delete_command(&cmd)
cmd_uninstall(&cmd)
bufio.writer_flush(cmd.out_buf)
output := strings.to_string(out_b)
testing.expect(
t,
strings.contains(output, "Nothing to remove"),
"output should say nothing to remove",
)
}
+66
View File
@@ -0,0 +1,66 @@
package main
import "base:runtime"
import "core:strings"
import "core:terminal/ansi"
Color_Code :: enum {
Heading,
Command,
// Example
Flag,
Table_Heading,
Message,
Sucess,
Caret,
Option_Label,
}
// COLOR_EXAMPLE :: ansi.CSI + ansi.ITALIC + ansi.SGR
@(private = "file")
ANSI_RESET :: ansi.CSI + ansi.RESET + ansi.SGR
disable_color := false
colorize :: proc(
color: Color_Code,
text: string,
allocator := context.temp_allocator,
disable := disable_color,
) -> (
string,
runtime.Allocator_Error,
) #optional_allocator_error {
if disable {
return text, nil
} else {
return strings.concatenate(
{ansi.CSI, color_code(color), ansi.SGR, text, ANSI_RESET},
allocator,
)
}
}
@(private = "file")
color_code :: proc(code: Color_Code) -> string {
switch code {
case .Heading:
return ansi.BOLD + ";" + ansi.UNDERLINE + ";" + ansi.FG_BRIGHT_GREEN
case .Command:
return ansi.BOLD + ";" + ansi.FG_BRIGHT_CYAN
case .Flag:
return ansi.BOLD + ";" + ansi.FG_BRIGHT_WHITE
case .Table_Heading:
return ansi.FG_BRIGHT_GREEN
case .Message:
return ansi.FAINT
case .Sucess, .Caret:
return ansi.BOLD + ";" + ansi.FG_GREEN
case .Option_Label:
return ansi.BOLD + ";" + ansi.FG_CYAN
case:
panic("Unknown case")
}
}
+163 -154
View File
@@ -1,26 +1,48 @@
package main
import "base:runtime"
import "core:encoding/json"
import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:strings"
import "findr"
Config :: struct {
keys: [dynamic]SshKeyPair `json:"keys"`,
scan_config: ScanConfig `json:"scan"`,
config_path: string `json:"-"`,
}
SshKeyPair :: struct {
Private: string `json:"private"`,
Public: string `json:"public"`,
private: string `json:"private"`,
public: string `json:"public"`,
}
ScanConfig :: struct {
Matcher: string `json:"matcher"`,
Exclude: [dynamic]string `json:"exclude"`,
Include: [dynamic]string `json:"include"`,
matcher: string `json:"matcher"`,
exclude: [dynamic]string `json:"exclude"`,
include: [dynamic]string `json:"include"`,
}
Config :: struct {
Keys: [dynamic]SshKeyPair `json:"keys"`,
ScanConfig: ScanConfig `json:"scan"`,
config_path: string `json:"-"`,
load_config :: proc(config_path: string, allocator := context.allocator) -> (Config, bool) {
// TODO: Should we use context.allocator + defer delete()?
data, read_err := os.read_entire_file_from_path(config_path, context.temp_allocator)
if read_err != nil {
fmt.eprintln("No config file found. Please run `envr init` to generate one.")
return Config{}, false
}
cfg: Config
err := json.unmarshal(data, &cfg, .JSON5, allocator)
if err != nil {
fmt.eprintf("Error parsing config: %v\n", err)
return Config{}, false
}
cfg.config_path = config_path
return cfg, true
}
default_config_path :: proc(home: string, allocator := context.allocator) -> string {
@@ -31,71 +53,116 @@ default_config_path :: proc(home: string, allocator := context.allocator) -> str
return path
}
load_config :: proc(config_path: string) -> (Config, bool) {
data, read_err := os.read_entire_file_from_path(config_path, context.allocator)
if read_err != nil {
fmt.println("No config file found. Please run `envr init` to generate one.")
return Config{}, false
delete_config :: proc(cfg: ^Config, allocator := context.allocator) {
for key in cfg.keys {
delete(key.private, allocator)
delete(key.public, allocator)
}
defer delete(data)
delete(cfg.keys)
cfg: Config
// TODO: use json 5
err := json.unmarshal(data, &cfg)
if err != nil {
fmt.printf("Error parsing config: %v\n", err)
return Config{}, false
delete(cfg.scan_config.matcher, allocator)
for exclude in cfg.scan_config.exclude {
delete(exclude, allocator)
}
cfg.config_path = config_path
delete(cfg.scan_config.exclude)
return cfg, true
for include in cfg.scan_config.include {
delete(include, allocator)
}
delete(cfg.scan_config.include)
}
delete_config :: proc(cfg: ^Config) {
for key in cfg.Keys {
delete(key.Private)
delete(key.Public)
}
delete(cfg.Keys)
save_config :: proc(cfg: Config, force: bool = false) -> bool {
config_dir := envr_dir(cfg.config_path)
delete(cfg.ScanConfig.Matcher)
for exclude in cfg.ScanConfig.Exclude {
delete(exclude)
if !os.exists(config_dir) {
mkdir_err := os.make_directory(config_dir)
if mkdir_err != nil {
fmt.eprintf("Error creating %s directory: %v\n", config_dir, mkdir_err)
return false
}
}
delete(cfg.ScanConfig.Exclude)
for include in cfg.ScanConfig.Include {
delete(include)
if os.exists(cfg.config_path) && !force {
info, stat_err := os.stat(cfg.config_path, context.temp_allocator)
if stat_err == nil {
defer os.file_info_delete(info, context.temp_allocator)
if info.size > 0 {
fmt.eprintln("Config file already exists. Run again with --force to reinitialize.")
return false
}
}
}
delete(cfg.ScanConfig.Include)
data, marshal_err := json.marshal(
cfg,
{pretty = true, use_spaces = true, spaces = 2},
context.temp_allocator,
)
if marshal_err != nil {
fmt.eprintf("Error marshaling config: %v\n", marshal_err)
return false
}
write_err := os.write_entire_file(cfg.config_path, data)
if write_err != nil {
fmt.eprintf("Error writing config: %v\n", write_err)
return false
}
return true
}
envr_dir :: proc(config_path: string) -> string {
return filepath.dir(config_path)
}
// Caller is responsible for calling delete_config()
new_config :: proc(
private_key_paths: []string,
cfg_path: string = "~/.envr/config.json",
) -> Config {
keys := make([dynamic]SshKeyPair, 0, len(private_key_paths))
for priv in private_key_paths {
// TODO: Is this bad?
priv_key := strings.clone(priv)
pub := strings.concatenate([]string{priv_key, ".pub"})
append(&keys, SshKeyPair{private = priv_key, public = pub})
}
data_path :: proc(config_path: string) -> string {
path, _ := filepath.join([]string{envr_dir(config_path), "data.envr"})
return path
// If we don't clone the strings, the cleanup semantics differ for Db created
// configs vs user created configs.
exclude := make([dynamic]string, 0, 4)
append(&exclude, strings.clone("*\\.envrc"))
append(&exclude, strings.clone("\\.local/"))
append(&exclude, strings.clone("node_modules"))
append(&exclude, strings.clone("vendor"))
include := make([dynamic]string, 0, 1)
append(&include, strings.clone("~"))
scan_cfg := ScanConfig {
matcher = strings.clone("\\.env"),
exclude = exclude,
include = include,
}
return Config{keys = keys, scan_config = scan_cfg, config_path = cfg_path}
}
find_ssh_private_keys :: proc() -> (keys: [dynamic]string, ok: bool) {
home, home_err := os.user_home_dir(context.allocator)
if home_err != nil {
fmt.printf("Error getting home dir: %v\n", home_err)
fmt.eprintf("Error getting home dir: %v\n", home_err)
return
}
ssh_dir, join_err := filepath.join([]string{home, ".ssh"})
if join_err != nil {
fmt.printf("Error building ssh path: %v\n", join_err)
fmt.eprintf("Error building ssh path: %v\n", join_err)
return
}
entries, dir_err := os.read_all_directory_by_path(ssh_dir, context.allocator)
if dir_err != nil {
fmt.printf("Could not read ~/.ssh directory: %v\n", dir_err)
fmt.eprintf("Could not read ~/.ssh directory: %v\n", dir_err)
return
}
defer os.file_info_slice_delete(entries, context.allocator)
@@ -126,115 +193,57 @@ find_ssh_private_keys :: proc() -> (keys: [dynamic]string, ok: bool) {
return
}
// Caller is responsible for calling delete_config()
new_config :: proc(
private_key_paths: []string,
cfg_path: string = "~/.envr/config.json",
) -> Config {
keys := make([dynamic]SshKeyPair, 0, len(private_key_paths))
for priv in private_key_paths {
// TODO: Is this bad?
priv_key := strings.clone(priv)
pub, _ := strings.concatenate([]string{priv_key, ".pub"})
append(&keys, SshKeyPair{Private = priv_key, Public = pub})
}
exclude := make([dynamic]string, 0, 4)
append(&exclude, strings.clone("*\\.envrc"))
append(&exclude, strings.clone("\\.local/"))
append(&exclude, strings.clone("node_modules"))
append(&exclude, strings.clone("vendor"))
include := make([dynamic]string, 0, 1)
append(&include, strings.clone("~"))
scan_cfg := ScanConfig {
Matcher = strings.clone("\\.env"),
Exclude = exclude,
Include = include,
}
return Config{Keys = keys, ScanConfig = scan_cfg, config_path = cfg_path}
}
save_config :: proc(cfg: Config, force: bool = false) -> bool {
config_dir := envr_dir(cfg.config_path)
if !os.exists(config_dir) {
mkdir_err := os.make_directory(config_dir)
if mkdir_err != nil {
fmt.printf("Error creating %s directory: %v\n", config_dir, mkdir_err)
return false
}
}
if os.exists(cfg.config_path) && !force {
info, stat_err := os.stat(cfg.config_path, context.allocator)
if stat_err == nil {
defer os.file_info_delete(info, context.allocator)
if info.size > 0 {
fmt.println("Config file already exists. Run again with --force to reinitialize.")
return false
}
}
}
data, marshal_err := json.marshal(cfg, {pretty = true, use_spaces = true, spaces = 2})
if marshal_err != nil {
fmt.printf("Error marshaling config: %v\n", marshal_err)
return false
}
defer delete(data)
write_err := os.write_entire_file(cfg.config_path, data)
if write_err != nil {
fmt.printf("Error writing config: %v\n", write_err)
return false
}
return true
}
search_paths :: proc(cfg: Config) -> (paths: [dynamic]string) {
// TODO: Is this okay?
// TODO: handle error
home, _ := os.user_home_dir(context.temp_allocator)
for include in cfg.ScanConfig.Include {
// TODO: Do we need to manually expand ~/ in odin?
expanded, _ := strings.replace(include, "~", home, 1)
if filepath.is_abs(expanded) {
append(&paths, expanded)
} else {
defer delete(expanded)
resolved, err := filepath.abs(expanded)
if err == nil {
append(&paths, resolved)
}
}
}
return
}
find_git_roots :: proc(cfg: Config) -> (roots: [dynamic]string, ok: bool) {
paths := search_paths(cfg)
for sp in paths {
args := []string{"fd", "-H", "-t", "d", "^\\.git$", sp}
lines, fd_ok := run_fd(args)
if !fd_ok {
return
}
for line in lines {
cleaned, _ := filepath.clean(line)
parent := filepath.dir(cleaned)
cloned, _ := strings.clone(parent)
append(&roots, cloned)
}
}
find_git_roots :: proc(
cfg: Config,
allocator := context.temp_allocator,
) -> (
roots: [dynamic]string,
ok: bool,
) {
paths := search_paths(cfg, allocator)
findr.find_repos(paths[:], &roots, os.get_processor_core_count(), allocator)
ok = true
return
}
search_paths :: proc(cfg: Config, allocator := context.allocator) -> [dynamic]string {
home, err := os.user_home_dir(context.temp_allocator)
if err != nil {
panic("Failed to find home directory")
}
paths := new_clone(cfg.scan_config.include, allocator)
defer free(paths, allocator)
for &include in paths {
expanded, _ := strings.replace(include, "~", home, 1, allocator)
if filepath.is_abs(expanded) {
include = expanded
} else {
// TODO: show errors?
resolved: string
resolved, err = filepath.abs(expanded, allocator)
if err == nil {
include = resolved
}
}
}
result := paths^
return result
}
// User is responsible for freeing the path
data_path :: proc(
config_path: string,
allocator := context.allocator,
) -> (
string,
runtime.Allocator_Error,
) #optional_allocator_error {
return filepath.join([]string{envr_dir(config_path), "data.envr"}, allocator)
}
envr_dir :: proc(config_path: string) -> string {
return filepath.dir(config_path)
}
+45 -47
View File
@@ -1,6 +1,8 @@
#+test
package main
import "core:fmt"
import "core:mem"
import "core:os"
import "core:path/filepath"
import "core:strings"
@@ -15,13 +17,9 @@ test_new_config_single_key :: proc(t: ^testing.T) {
cfg := new_config(paths)
defer delete_config(&cfg)
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].Public == "/home/user/.ssh/id_ed25519.pub",
"Public path mismatch",
)
testing.expect_value(t, len(cfg.keys), 1)
testing.expect_value(t, cfg.keys[0].private, "/home/user/.ssh/id_ed25519")
testing.expect_value(t, cfg.keys[0].public, "/home/user/.ssh/id_ed25519.pub")
}
@(test)
@@ -30,9 +28,9 @@ test_new_config_multiple_keys :: proc(t: ^testing.T) {
cfg := new_config(paths)
defer delete_config(&cfg)
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[1].Private == "/home/user/.ssh/id_rsa")
testing.expect_value(t, len(cfg.keys), 2)
testing.expect_value(t, cfg.keys[0].private, "/home/user/.ssh/id_ed25519")
testing.expect_value(t, cfg.keys[1].private, "/home/user/.ssh/id_rsa")
}
@(test)
@@ -41,7 +39,7 @@ test_new_config_empty_keys :: proc(t: ^testing.T) {
cfg := new_config(paths)
defer delete_config(&cfg)
testing.expect(t, len(cfg.Keys) == 0, "should have 0 keys")
testing.expect_value(t, len(cfg.keys), 0)
}
@(test)
@@ -50,10 +48,10 @@ test_new_config_scan_defaults :: proc(t: ^testing.T) {
cfg := new_config(paths)
defer delete_config(&cfg)
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.Include) == 1, "should have 1 include path")
testing.expect(t, cfg.ScanConfig.Include[0] == "~", "include should be ~")
testing.expect_value(t, cfg.scan_config.matcher, "\\.env")
testing.expect_value(t, len(cfg.scan_config.exclude), 4)
testing.expect_value(t, len(cfg.scan_config.include), 1)
testing.expect_value(t, cfg.scan_config.include[0], "~")
}
@(test)
@@ -64,18 +62,17 @@ test_new_config_exclude_patterns :: proc(t: ^testing.T) {
expected := []string{"*\\.envrc", "\\.local/", "node_modules", "vendor"}
for i in 0 ..< len(expected) {
testing.expect(t, cfg.ScanConfig.Exclude[i] == expected[i])
testing.expect_value(t, cfg.scan_config.exclude[i], expected[i])
}
}
@(test)
test_save_load_config_roundtrip :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-cfg-rt-%d", os.get_pid())
os.mkdir_all(base)
base := test_temp_dir(t, "envr-test-cfg-rt-*")
defer os.remove_all(base)
cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect(t, err == nil, "cfgPath should build successfully")
testing.expect_value(t, err, nil)
cfg := new_config([]string{"/home/user/.ssh/id_ed25519"}, cfgPath)
defer delete_config(&cfg)
@@ -87,13 +84,13 @@ test_save_load_config_roundtrip :: proc(t: ^testing.T) {
if !ok do return
defer delete_config(&loaded)
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].Public == "/home/user/.ssh/id_ed25519.pub")
testing.expect(t, loaded.ScanConfig.Matcher == "\\.env")
testing.expect(t, len(loaded.ScanConfig.Exclude) == 4)
testing.expect(t, len(loaded.ScanConfig.Include) == 1)
testing.expect(t, loaded.ScanConfig.Include[0] == "~")
testing.expect_value(t, len(loaded.keys), 1)
testing.expect_value(t, loaded.keys[0].private, "/home/user/.ssh/id_ed25519")
testing.expect_value(t, loaded.keys[0].public, "/home/user/.ssh/id_ed25519.pub")
testing.expect_value(t, loaded.scan_config.matcher, "\\.env")
testing.expect_value(t, len(loaded.scan_config.exclude), 4)
testing.expect_value(t, len(loaded.scan_config.include), 1)
testing.expect_value(t, loaded.scan_config.include[0], "~")
}
@(test)
@@ -104,12 +101,11 @@ test_load_config_missing :: proc(t: ^testing.T) {
@(test)
test_save_config_no_clobber :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-cfg-noclobber-%d", os.get_pid())
os.mkdir_all(base)
base := test_temp_dir(t, "envr-test-cfg-noclobber-*")
defer os.remove_all(base)
cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect(t, err == nil, "cfgPath should build successfully")
testing.expect_value(t, err, nil)
cfg := new_config([]string{"/home/user/.ssh/key1"}, cfgPath)
defer delete_config(&cfg)
@@ -122,12 +118,11 @@ test_save_config_no_clobber :: proc(t: ^testing.T) {
@(test)
test_save_config_force_overwrites :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-cfg-force-%d", os.get_pid())
os.mkdir_all(base)
base := test_temp_dir(t, "envr-test-cfg-force-*")
defer os.remove_all(base)
cfgPath, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect(t, err == nil, "cfgPath should build successfully")
testing.expect_value(t, err, nil)
cfg := new_config([]string{"/home/user/.ssh/key1"}, cfgPath)
defer delete_config(&cfg)
@@ -142,12 +137,8 @@ test_save_config_force_overwrites :: proc(t: ^testing.T) {
if !ok do return
defer delete_config(&loaded)
testing.expect(t, len(loaded.Keys) == 1, "should have 1 key")
testing.expect(
t,
loaded.Keys[0].Private == "/home/user/.ssh/key2",
"should be the overwritten key",
)
testing.expect_value(t, len(loaded.keys), 1)
testing.expect_value(t, loaded.keys[0].private, "/home/user/.ssh/key2")
}
@(test)
@@ -185,18 +176,14 @@ test_search_paths_expands_tilde :: proc(t: ^testing.T) {
os.set_env("HOME", "/tmp/envr-fake-home-search")
cfg := Config {
ScanConfig = ScanConfig{Include = make([dynamic]string, 0, 1)},
scan_config = ScanConfig{include = make([dynamic]string, 0, 1)},
}
defer delete(cfg.ScanConfig.Include)
append(&cfg.ScanConfig.Include, "~")
append(&cfg.scan_config.include, "~")
defer delete(cfg.scan_config.include)
paths := search_paths(cfg)
defer delete(paths)
for path in paths {
defer delete(path)
}
paths := search_paths(cfg, context.temp_allocator)
testing.expect(t, len(paths) == 1, "should have 1 path")
testing.expect_value(t, len(paths), 1)
if len(paths) > 0 {
testing.expectf(
t,
@@ -208,3 +195,14 @@ test_search_paths_expands_tilde :: proc(t: ^testing.T) {
}
}
@(test)
test_search_paths_no_leak :: proc(t: ^testing.T) {
cfg := Config {
scan_config = ScanConfig{include = make([dynamic]string, 0, 1)},
}
defer delete(cfg.scan_config.include)
append(&cfg.scan_config.include, "/tmp")
_ = search_paths(cfg, context.allocator)
}
+74 -87
View File
@@ -2,9 +2,10 @@ package main
import "core:fmt"
import "core:mem"
import "core:os"
MAGIC :: "ENVR"
MAGIC_BYTES := [4]u8{u8('E'), u8('N'), u8('V'), u8('R')}
MAGIC_BYTES: [4]u8 = MAGIC
RECIPIENT_ENTRY_SIZE ::
CRYPTO_BOX_PUBLICKEY_BYTES +
@@ -20,85 +21,24 @@ RecipientEntry :: struct {
EncryptedKey: [CRYPTO_SECRETBOX_KEY_BYTES + CRYPTO_BOX_MAC_BYTES]u8,
}
sodium_initialized: bool
ensure_sodium :: proc() -> bool {
if sodium_initialized {
return true
}
rc := sodium_init()
if rc < 0 {
fmt.println("Error: libsodium initialization failed")
return false
}
sodium_initialized = true
return true
}
X25519Keypair :: struct {
Public: [CRYPTO_BOX_PUBLICKEY_BYTES]u8,
Private: [CRYPTO_BOX_SECRETKEY_BYTES]u8,
}
ssh_to_x25519 :: proc(keys: []SshKeyPair) -> (pairs: []X25519Keypair, ok: bool) {
if len(keys) == 0 {
return
@(init)
init_sodium :: proc "contextless" () {
if sodium_init() < 0 {
os.exit(1)
}
pairs = make([]X25519Keypair, len(keys))
for i in 0 ..< len(keys) {
ssh_kp, parse_ok := parse_ssh_private_key(keys[i].Private)
if !parse_ok {
fmt.printf("Error: failed to parse SSH private key: %s\n", keys[i].Private)
delete(pairs)
return
}
ssh_pub, pub_ok := parse_ssh_public_key(keys[i].Public)
if !pub_ok {
fmt.printf("Error: failed to parse SSH public key: %s\n", keys[i].Public)
delete(pairs)
return
}
pk_rc := crypto_sign_ed25519_pk_to_curve25519(&pairs[i].Public[0], &ssh_pub[0])
if pk_rc != 0 {
fmt.println("Error: failed to convert ed25519 public key to curve25519")
delete(pairs)
return
}
ed25519_sk: [64]u8
for j in 0 ..< 32 {
ed25519_sk[j] = ssh_kp.Private[j]
}
for j in 0 ..< 32 {
ed25519_sk[32 + j] = ssh_kp.Public[j]
}
sk_rc := crypto_sign_ed25519_sk_to_curve25519(&pairs[i].Private[0], &ed25519_sk[0])
if sk_rc != 0 {
fmt.println("Error: failed to convert ed25519 private key to curve25519")
delete(pairs)
return
}
}
ok = true
return
}
// TODO: Optimize performance
encrypt :: proc(plaintext: []u8, keys: []SshKeyPair) -> (ciphertext: []u8, ok: bool) {
if !ensure_sodium() {
return
}
x25519_pairs, pairs_ok := ssh_to_x25519(keys)
x25519_pairs, pairs_ok := ssh_to_x25519(keys, context.temp_allocator)
if !pairs_ok {
return
}
defer delete(x25519_pairs)
sym_key: [CRYPTO_SECRETBOX_KEY_BYTES]u8
randombytes_buf(&sym_key[0], CRYPTO_SECRETBOX_KEY_BYTES)
@@ -107,7 +47,7 @@ encrypt :: proc(plaintext: []u8, keys: []SshKeyPair) -> (ciphertext: []u8, ok: b
randombytes_buf(&main_nonce[0], CRYPTO_SECRETBOX_NONCE_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
if len(plaintext) > 0 {
pt_ptr = &plaintext[0]
@@ -120,13 +60,13 @@ encrypt :: proc(plaintext: []u8, keys: []SshKeyPair) -> (ciphertext: []u8, ok: b
&sym_key[0],
)
if rc != 0 {
fmt.println("Error: symmetric encryption failed")
fmt.eprintln("Error: symmetric encryption failed")
delete(secret_ct)
return
}
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 j in 0 ..< CRYPTO_BOX_PUBLICKEY_BYTES {
@@ -144,7 +84,7 @@ encrypt :: proc(plaintext: []u8, keys: []SshKeyPair) -> (ciphertext: []u8, ok: b
&x25519_pairs[0].Private[0],
)
if rc != 0 {
fmt.printf("Error: failed to encrypt for recipient %d\n", i)
fmt.eprintf("Error: failed to encrypt for recipient %d\n", i)
delete(entries)
delete(secret_ct)
return
@@ -186,25 +126,19 @@ encrypt :: proc(plaintext: []u8, keys: []SshKeyPair) -> (ciphertext: []u8, ok: b
mem.copy(&ciphertext[pos], &secret_ct[0], ct_len)
delete(entries)
delete(secret_ct)
ok = true
return
}
decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: bool) {
if !ensure_sodium() {
return
}
if len(ciphertext) < HEADER_SIZE {
fmt.println("Error: ciphertext too short (header)")
fmt.eprintln("Error: ciphertext too short (header)")
return
}
for i in 0 ..< 4 {
if ciphertext[i] != MAGIC_BYTES[i] {
fmt.println("Error: invalid magic bytes")
fmt.eprintln("Error: invalid magic bytes")
return
}
}
@@ -232,7 +166,7 @@ decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: b
recipients_end := offset + int(num_recipients) * RECIPIENT_ENTRY_SIZE
if recipients_end > len(ciphertext) {
fmt.println("Error: ciphertext too short (recipient data)")
fmt.eprintln("Error: ciphertext too short (recipient data)")
return
}
@@ -240,11 +174,10 @@ decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: b
enc_nonce: [CRYPTO_BOX_NONCE_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 {
return
}
defer delete(x25519_pairs)
found := false
matched_pi := 0
@@ -289,7 +222,7 @@ decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: b
}
if !found {
fmt.println("Error: no matching recipient found")
fmt.eprintln("Error: no matching recipient found")
return
}
@@ -303,14 +236,14 @@ decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: b
&x25519_pairs[matched_pi].Private[0],
)
if rc != 0 {
fmt.println("Error: failed to decrypt symmetric key")
fmt.eprintln("Error: failed to decrypt symmetric key")
return
}
ct_data := ciphertext[recipients_end:]
pt_len := len(ct_data) - CRYPTO_SECRETBOX_MAC_BYTES
if pt_len < 0 {
fmt.println("Error: ciphertext too short (no encrypted data)")
fmt.eprintln("Error: ciphertext too short (no encrypted data)")
return
}
@@ -327,7 +260,7 @@ decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: b
&sym_key[0],
)
if rc != 0 {
fmt.println("Error: symmetric decryption failed")
fmt.eprintln("Error: symmetric decryption failed")
delete(plaintext)
return
}
@@ -336,3 +269,57 @@ decrypt :: proc(ciphertext: []u8, keys: []SshKeyPair) -> (plaintext: []u8, ok: b
return
}
ssh_to_x25519 :: proc(
keys: []SshKeyPair,
allocator := context.temp_allocator,
) -> (
[]X25519Keypair,
bool,
) {
if len(keys) == 0 {
return {}, false
}
pairs := make([]X25519Keypair, len(keys), allocator)
for i in 0 ..< len(keys) {
ssh_kp, parse_ok := parse_ssh_private_key(keys[i].private)
if !parse_ok {
fmt.eprintf("Error: failed to parse SSH private key: %s\n", keys[i].private)
delete(pairs)
return pairs, false
}
ssh_pub, pub_ok := parse_ssh_public_key(keys[i].public)
if !pub_ok {
fmt.eprintf("Error: failed to parse SSH public key: %s\n", keys[i].public)
delete(pairs)
return pairs, false
}
pk_rc := crypto_sign_ed25519_pk_to_curve25519(&pairs[i].Public[0], &ssh_pub[0])
if pk_rc != 0 {
fmt.eprintln("Error: failed to convert ed25519 public key to curve25519")
delete(pairs)
return pairs, false
}
ed25519_sk: [64]u8
for j in 0 ..< 32 {
ed25519_sk[j] = ssh_kp.Private[j]
}
for j in 0 ..< 32 {
ed25519_sk[32 + j] = ssh_kp.Public[j]
}
sk_rc := crypto_sign_ed25519_sk_to_curve25519(&pairs[i].Private[0], &ed25519_sk[0])
if sk_rc != 0 {
fmt.eprintln("Error: failed to convert ed25519 private key to curve25519")
delete(pairs)
return pairs, false
}
}
return pairs, true
}
+16 -24
View File
@@ -1,14 +1,16 @@
#+test
package main
import "core:fmt"
import "core:os"
import "core:testing"
CRYPTO_TEST_KEY_DIR :: "fixtures/keys"
CRYPTO_TEST_KEY_DIR :: "fixtures" + os.Path_Separator_String + "keys"
make_test_key_pair :: proc(name: string) -> SshKeyPair {
priv := fmt.tprintf("%s/%s", CRYPTO_TEST_KEY_DIR, name)
pub := fmt.tprintf("%s/%s.pub", CRYPTO_TEST_KEY_DIR, name)
return SshKeyPair{Private = priv, Public = pub}
return SshKeyPair{private = priv, public = pub}
}
@(test)
@@ -25,13 +27,10 @@ test_encrypt_decrypt_roundtrip :: proc(t: ^testing.T) {
testing.expect(t, dec_ok, "decryption should succeed")
defer delete(decrypted)
testing.expect(
t,
len(decrypted) == len(original),
fmt.tprintf("expected %d bytes, got %d", len(original), len(decrypted)),
)
testing.expect_value(t, len(decrypted), len(original))
// TODO: Should this be a loop?
for i in 0 ..< len(original) {
testing.expect(t, decrypted[i] == original[i], fmt.tprintf("byte mismatch at index %d", i))
testing.expect_value(t, decrypted[i], original[i])
}
}
@@ -54,16 +53,8 @@ test_encrypt_decrypt_multi_recipient :: proc(t: ^testing.T) {
defer delete(decrypted2)
for i in 0 ..< len(original) {
testing.expect(
t,
decrypted1[i] == original[i],
fmt.tprintf("key1: byte mismatch at %d", i),
)
testing.expect(
t,
decrypted2[i] == original[i],
fmt.tprintf("key2: byte mismatch at %d", i),
)
testing.expect_value(t, decrypted1[i], original[i])
testing.expect_value(t, decrypted2[i], original[i])
}
}
@@ -94,7 +85,7 @@ test_encrypt_empty_plaintext :: proc(t: ^testing.T) {
testing.expect(t, dec_ok, "decryption should succeed")
defer delete(decrypted)
testing.expect(t, len(decrypted) == 0, "decrypted empty data should be empty")
testing.expect_value(t, len(decrypted), 0)
}
@(test)
@@ -111,8 +102,9 @@ test_recipient_can_decrypt_senders_data :: proc(t: ^testing.T) {
testing.expect(t, dec_ok, "second recipient should decrypt without the sender key present")
defer delete(decrypted)
// TODO: Should this be a loop?
for i in 0 ..< len(original) {
testing.expect(t, decrypted[i] == original[i], fmt.tprintf("byte mismatch at %d", i))
testing.expect_value(t, decrypted[i], original[i])
}
}
@@ -126,9 +118,9 @@ test_ciphertext_has_magic :: proc(t: ^testing.T) {
defer delete(encrypted)
testing.expect(t, len(encrypted) >= 4, "ciphertext should have at least 4 bytes")
testing.expect(t, encrypted[0] == u8('E'), "magic byte 0")
testing.expect(t, encrypted[1] == u8('N'), "magic byte 1")
testing.expect(t, encrypted[2] == u8('V'), "magic byte 2")
testing.expect(t, encrypted[3] == u8('R'), "magic byte 3")
testing.expect_value(t, encrypted[0], u8('E'))
testing.expect_value(t, encrypted[1], u8('N'))
testing.expect_value(t, encrypted[2], u8('V'))
testing.expect_value(t, encrypted[3], u8('R'))
}
+387 -430
View File
File diff suppressed because it is too large Load Diff
+61 -63
View File
@@ -1,3 +1,4 @@
#+test
package main
import "core:fmt"
@@ -10,6 +11,14 @@ import "sqlite"
FIXTURES :: "fixtures"
test_temp_dir :: proc(t: ^testing.T, prefix: string) -> string {
dir, err := os.mkdir_temp("", prefix, context.temp_allocator)
if err != nil {
testing.fail_now(t, fmt.tprintf("Failed to create temp dir: %v", err))
}
return dir
}
fixture_key :: proc() -> SshKeyPair {
priv, _ := strings.concatenate(
[]string{FIXTURES, "/keys/insecure-test-key"},
@@ -19,7 +28,7 @@ fixture_key :: proc() -> SshKeyPair {
[]string{FIXTURES, "/keys/insecure-test-key.pub"},
context.temp_allocator,
)
return SshKeyPair{Private = priv, Public = pub}
return SshKeyPair{private = priv, public = pub}
}
fixture_db_path :: proc() -> string {
@@ -29,9 +38,9 @@ fixture_db_path :: proc() -> string {
fixture_config :: proc() -> Config {
cfg := Config {
Keys = make([dynamic]SshKeyPair, 0, 1),
keys = make([dynamic]SshKeyPair, 0, 1),
}
append(&cfg.Keys, fixture_key())
append(&cfg.keys, fixture_key())
return cfg
}
@@ -39,7 +48,7 @@ fixture_config :: proc() -> Config {
test_encrypt_decrypt_sqlite_roundtrip :: proc(t: ^testing.T) {
cfg := fixture_config()
defer {
delete(cfg.Keys)
delete(cfg.keys)
}
db_path := fixture_db_path()
@@ -50,7 +59,7 @@ test_encrypt_decrypt_sqlite_roundtrip :: proc(t: ^testing.T) {
}
defer delete(sqlite_data)
encrypted, enc_ok := encrypt(sqlite_data, cfg.Keys[:])
encrypted, enc_ok := encrypt(sqlite_data, cfg.keys[:])
testing.expect(t, enc_ok, "encryption should succeed")
if !enc_ok {
return
@@ -58,12 +67,12 @@ test_encrypt_decrypt_sqlite_roundtrip :: proc(t: ^testing.T) {
defer delete(encrypted)
testing.expect(t, len(encrypted) >= HEADER_SIZE, "ciphertext should have header")
testing.expect(t, encrypted[0] == u8('E'), "magic byte 0")
testing.expect(t, encrypted[1] == u8('N'), "magic byte 1")
testing.expect(t, encrypted[2] == u8('V'), "magic byte 2")
testing.expect(t, encrypted[3] == u8('R'), "magic byte 3")
testing.expect_value(t, encrypted[0], u8('E'))
testing.expect_value(t, encrypted[1], u8('N'))
testing.expect_value(t, encrypted[2], u8('V'))
testing.expect_value(t, encrypted[3], u8('R'))
plaintext, dec_ok := decrypt(encrypted, cfg.Keys[:])
plaintext, dec_ok := decrypt(encrypted, cfg.keys[:])
testing.expect(t, dec_ok, "decryption should succeed")
if !dec_ok {
return
@@ -92,7 +101,7 @@ test_encrypt_decrypt_sqlite_roundtrip :: proc(t: ^testing.T) {
test_encrypt_write_read_decrypt :: proc(t: ^testing.T) {
cfg := fixture_config()
defer {
delete(cfg.Keys)
delete(cfg.keys)
}
db_path := fixture_db_path()
@@ -103,20 +112,21 @@ test_encrypt_write_read_decrypt :: proc(t: ^testing.T) {
}
defer delete(sqlite_data)
encrypted, enc_ok := encrypt(sqlite_data, cfg.Keys[:])
encrypted, enc_ok := encrypt(sqlite_data, cfg.keys[:])
testing.expect(t, enc_ok, "encryption should succeed")
if !enc_ok {
return
}
defer delete(encrypted)
tmp_enc_path := fmt.tprintf("/tmp/envr-test-ewrd-%d.envr", os.get_pid())
ewrd_dir := test_temp_dir(t, "envr-test-ewrd-*")
defer os.remove_all(ewrd_dir)
tmp_enc_path, _ := filepath.join([]string{ewrd_dir, "data.envr"}, context.temp_allocator)
write_err := os.write_entire_file(tmp_enc_path, encrypted)
testing.expectf(t, write_err == nil, "failed to write encrypted file: %v", write_err)
if write_err != nil {
return
}
defer os.remove(tmp_enc_path)
read_back, rb_err := os.read_entire_file_from_path(tmp_enc_path, context.allocator)
testing.expectf(t, rb_err == nil, "failed to read back encrypted file: %v", rb_err)
@@ -125,21 +135,21 @@ test_encrypt_write_read_decrypt :: proc(t: ^testing.T) {
}
defer delete(read_back)
plaintext, dec_ok := decrypt(read_back, cfg.Keys[:])
plaintext, dec_ok := decrypt(read_back, cfg.keys[:])
testing.expect(t, dec_ok, "decryption after write/read should succeed")
if !dec_ok {
return
}
defer delete(plaintext)
testing.expect(t, len(plaintext) == len(sqlite_data), "size mismatch after file round-trip")
testing.expect_value(t, len(plaintext), len(sqlite_data))
}
@(test)
test_decrypt_then_deserialize_sqlite :: proc(t: ^testing.T) {
cfg := fixture_config()
defer {
delete(cfg.Keys)
delete(cfg.keys)
}
db_path := fixture_db_path()
@@ -150,27 +160,27 @@ test_decrypt_then_deserialize_sqlite :: proc(t: ^testing.T) {
}
defer delete(sqlite_data)
encrypted, enc_ok := encrypt(sqlite_data, cfg.Keys[:])
encrypted, enc_ok := encrypt(sqlite_data, cfg.keys[:])
testing.expect(t, enc_ok, "encryption should succeed")
if !enc_ok {
return
}
defer delete(encrypted)
plaintext, dec_ok := decrypt(encrypted, cfg.Keys[:])
plaintext, dec_ok := decrypt(encrypted, cfg.keys[:])
testing.expect(t, dec_ok, "decryption should succeed")
if !dec_ok {
return
}
defer delete(plaintext)
mem_db: ^rawptr
rc := sqlite.db_open(":memory:", &mem_db)
mem_db: sqlite.Db
rc := sqlite.open(":memory:", &mem_db)
testing.expectf(t, rc == sqlite.OK, "failed to open in-memory db")
if rc != sqlite.OK {
return
}
defer sqlite.db_close(mem_db)
defer sqlite.close(mem_db)
n := i64(len(plaintext))
buf := sqlite.malloc64(n)
@@ -178,31 +188,24 @@ test_decrypt_then_deserialize_sqlite :: proc(t: ^testing.T) {
if buf == nil do return
copy(buf[:len(plaintext)], plaintext)
rc = sqlite.deserialize(
mem_db,
"main",
buf,
n,
n,
sqlite.DESERIALIZE_FREEONCLOSE | sqlite.DESERIALIZE_RESIZEABLE,
)
testing.expect(t, rc == sqlite.OK, "deserialize should succeed")
rc = sqlite.deserialize(mem_db, "main", buf, n, n, {.FREEONCLOSE, .RESIZEABLE})
testing.expect_value(t, rc, sqlite.OK)
if rc != sqlite.OK {
sqlite.free(buf)
return
}
sql: cstring = "SELECT path FROM envr_env_files"
stmt: ^rawptr
stmt: sqlite.Stmt
rc = sqlite.prepare_v2(mem_db, sql, -1, &stmt, nil)
testing.expect(t, rc == sqlite.OK, "prepare failed")
testing.expect_value(t, rc, sqlite.OK)
if rc != sqlite.OK {
return
}
defer sqlite.finalize(stmt)
rc = sqlite.step(stmt)
testing.expect(t, rc == sqlite.ROW, "expected at least one row")
testing.expect_value(t, rc, sqlite.ROW)
if rc == sqlite.ROW {
path := string(sqlite.column_text(stmt, 0))
testing.expect(t, len(path) > 0, "path should not be empty")
@@ -212,7 +215,7 @@ test_decrypt_then_deserialize_sqlite :: proc(t: ^testing.T) {
@(test)
test_full_db_cycle :: proc(t: ^testing.T) {
cfg := fixture_config()
defer delete(cfg.Keys)
defer delete(cfg.keys)
db_path := fixture_db_path()
original_data, read_err := os.read_entire_file_from_path(db_path, context.allocator)
@@ -222,18 +225,22 @@ test_full_db_cycle :: proc(t: ^testing.T) {
}
defer delete(original_data)
encrypted, enc_ok := encrypt(original_data, cfg.Keys[:])
encrypted, enc_ok := encrypt(original_data, cfg.keys[:])
testing.expect(t, enc_ok, "first encryption should succeed")
if !enc_ok {
return
}
defer delete(encrypted)
envr_dir_path := fmt.tprintf("/tmp/envr-test-cycle-%d/.envr", os.get_pid())
os.mkdir_all(envr_dir_path)
cycle_dir := test_temp_dir(t, "envr-test-cycle-*")
defer os.remove_all(cycle_dir)
envr_dir_path, _ := filepath.join([]string{cycle_dir, ".envr"}, context.temp_allocator)
{
err := os.mkdir_all(envr_dir_path)
testing.expect_value(t, err, nil)
}
data_path, _ := filepath.join([]string{envr_dir_path, "data.envr"})
defer delete(data_path)
data_path, _ := filepath.join([]string{envr_dir_path, "data.envr"}, context.temp_allocator)
write_err := os.write_entire_file(data_path, encrypted)
testing.expectf(t, write_err == nil, "failed to write data.envr: %v", write_err)
if write_err != nil {
@@ -247,36 +254,28 @@ test_full_db_cycle :: proc(t: ^testing.T) {
}
defer delete(read_back)
plaintext, dec_ok := decrypt(read_back, cfg.Keys[:])
plaintext, dec_ok := decrypt(read_back, cfg.keys[:])
testing.expect(t, dec_ok, "decryption should succeed")
if !dec_ok {
return
}
defer delete(plaintext)
encrypted2, enc2_ok := encrypt(plaintext, cfg.Keys[:])
encrypted2, enc2_ok := encrypt(plaintext, cfg.keys[:])
testing.expect(t, enc2_ok, "re-encryption should succeed")
if !enc2_ok {
return
}
defer delete(encrypted2)
plaintext2, dec2_ok := decrypt(encrypted2, cfg.Keys[:])
plaintext2, dec2_ok := decrypt(encrypted2, cfg.keys[:])
testing.expect(t, dec2_ok, "second decryption should succeed")
if !dec2_ok {
return
}
defer delete(plaintext2)
testing.expect(
t,
len(plaintext2) == len(original_data),
fmt.tprintf(
"double round-trip size mismatch: expected %d, got %d",
len(original_data),
len(plaintext2),
),
)
testing.expect_value(t, len(plaintext2), len(original_data))
os.remove(data_path)
os.remove(envr_dir_path)
@@ -288,13 +287,13 @@ test_full_db_cycle :: proc(t: ^testing.T) {
test_ssh_key_parse_from_fixtures :: proc(t: ^testing.T) {
key := fixture_key()
priv_kp, priv_ok := parse_ssh_private_key(key.Private)
priv_kp, priv_ok := parse_ssh_private_key(key.private)
testing.expect(t, priv_ok, "should parse private key from fixtures")
if !priv_ok {
return
}
pub_key, pub_ok := parse_ssh_public_key(key.Public)
pub_key, pub_ok := parse_ssh_public_key(key.public)
testing.expect(t, pub_ok, "should parse public key from fixtures")
if !pub_ok {
return
@@ -309,29 +308,28 @@ test_ssh_key_parse_from_fixtures :: proc(t: ^testing.T) {
if !x_ok {
return
}
defer delete(x25519_pairs)
testing.expect(t, len(x25519_pairs) == 1, "should have 1 x25519 keypair")
testing.expect_value(t, len(x25519_pairs), 1)
}
@(test)
test_config_load_with_fixture_key :: proc(t: ^testing.T) {
cfg := fixture_config()
defer {
delete(cfg.Keys)
delete(cfg.keys)
}
testing.expect(t, len(cfg.Keys) == 1, "should have 1 key")
testing.expect_value(t, len(cfg.keys), 1)
key := cfg.Keys[0]
key := cfg.keys[0]
testing.expectf(t, len(key.Private) > 0, "private key path should not be empty")
testing.expectf(t, len(key.Public) > 0, "public key path should not be empty")
testing.expectf(t, len(key.private) > 0, "private key path should not be empty")
testing.expectf(t, len(key.public) > 0, "public key path should not be empty")
_, priv_ok := parse_ssh_private_key(key.Private)
_, priv_ok := parse_ssh_private_key(key.private)
testing.expect(t, priv_ok, "should parse private key using config paths")
if !priv_ok {
fmt.printf(" private key path was: '%s'\n", key.Private)
fmt.printf(" private key path was: '%s'\n", key.private)
}
}
+410 -182
View File
@@ -1,232 +1,203 @@
#+test
package main
import "core:crypto/hash"
import "core:encoding/hex"
import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:strings"
import "core:sys/posix"
import "core:testing"
import "sqlite"
make_test_db :: proc() -> (Db, bool) {
db: ^rawptr
rc := sqlite.db_open(":memory:", &db)
if rc != sqlite.OK {
return Db{}, false
}
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, create_sql, nil, nil, nil)
if rc != sqlite.OK {
sqlite.db_close(db)
return Db{}, false
}
return Db{db = db}, true
}
make_test_env_file :: proc(path, sha, contents: string, remotes: []string = {}) -> EnvFile {
f := EnvFile {
Path = path,
Dir = "",
Sha256 = sha,
path = path,
dir = "",
sha256 = sha,
contents = contents,
Remotes = make([dynamic]string, 0, len(remotes)),
remotes = make([dynamic]string, 0, len(remotes), context.temp_allocator),
}
for r in remotes {
append(&f.Remotes, r)
append(&f.remotes, r)
}
return f
}
@(test)
test_db_insert_and_fetch :: proc(t: ^testing.T) {
d, ok := make_test_db()
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
defer db_close(&db)
path := "/project/.env"
sha := "abc123"
contents := "SECRET=value"
f := make_test_env_file(path, sha, contents, []string{"git@github.com:user/repo.git"})
defer delete(f.Remotes)
defer delete(f.remotes)
testing.expect(t, db_insert(&d, f), "insert should succeed")
testing.expect(t, db_insert(&db, f), "insert should succeed")
fetched, fetch_ok := db_fetch(&d, "/project/.env")
defer delete_envfile(&fetched)
fetched, fetch_ok := db_fetch(&db, "/project/.env")
// defer delete_envfile(&fetched)
testing.expect(t, fetch_ok, "fetch should succeed")
if !fetch_ok do return
testing.expect_value(t, fetched.Path, path)
testing.expect_value(t, fetched.Sha256, sha)
testing.expect_value(t, fetched.path, path)
testing.expect_value(t, fetched.sha256, sha)
testing.expect_value(t, fetched.contents, contents)
testing.expect_value(t, len(fetched.Remotes), 1)
testing.expect_value(t, fetched.Remotes[0], "git@github.com:user/repo.git")
testing.expect_value(t, len(fetched.remotes), 1)
testing.expect_value(t, fetched.remotes[0], "git@github.com:user/repo.git")
}
@(test)
test_db_fetch_missing :: proc(t: ^testing.T) {
d, ok := make_test_db()
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
defer db_close(&db)
_, fetch_ok := db_fetch(&d, "/nonexistent/.env")
_, fetch_ok := db_fetch(&db, "/nonexistent/.env")
testing.expect(t, !fetch_ok, "fetch missing should return false")
}
@(test)
test_db_insert_or_replace :: proc(t: ^testing.T) {
d, ok := make_test_db()
db, ok := db_init()
defer db_close(&db)
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
f1 := make_test_env_file("/project/.env", "sha1", "KEY=old")
defer delete(f1.Remotes)
testing.expect(t, db_insert(&d, f1), "first insert should succeed")
defer delete(f1.remotes)
testing.expect(t, db_insert(&db, f1), "first insert should succeed")
f2 := make_test_env_file("/project/.env", "sha2", "KEY=new")
defer delete(f2.Remotes)
testing.expect(t, db_insert(&d, f2), "second insert should succeed")
defer delete(f2.remotes)
testing.expect(t, db_insert(&db, f2), "second insert should succeed")
results, list_ok := db_list(&d)
results, list_ok := db_list(&db)
testing.expect(t, list_ok, "list should succeed")
if !list_ok do return
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_value(t, len(results), 1)
fetched, fetch_ok := db_fetch(&d, "/project/.env")
fetched, fetch_ok := db_fetch(&db, "/project/.env")
testing.expect(t, fetch_ok, "fetch should succeed")
if !fetch_ok do return
defer delete_envfile(&fetched)
// defer delete_envfile(&fetched)
testing.expect_value(t, fetched.contents, "KEY=new")
testing.expect_value(t, fetched.Sha256, "sha2")
testing.expect_value(t, fetched.sha256, "sha2")
}
@(test)
test_db_delete_existing :: proc(t: ^testing.T) {
d, ok := make_test_db()
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
defer db_close(&db)
f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.Remotes)
db_insert(&d, f)
defer delete(f.remotes)
db_insert(&db, f)
testing.expect(t, db_delete(&d, "/project/.env"), "delete should return true")
testing.expect(t, db_delete(&db, "/project/.env"), "delete should return true")
_, fetch_ok := db_fetch(&d, "/project/.env")
_, fetch_ok := db_fetch(&db, "/project/.env")
testing.expect(t, !fetch_ok, "row should be gone after delete")
}
@(test)
test_db_delete_missing :: proc(t: ^testing.T) {
d, ok := make_test_db()
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
defer db_close(&db)
testing.expect(t, !db_delete(&d, "/nonexistent/.env"), "delete missing should return false")
testing.expect(t, !db_delete(&db, "/nonexistent/.env"), "delete missing should return false")
}
@(test)
test_db_list_multiple :: proc(t: ^testing.T) {
d, ok := make_test_db()
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
defer db_close(&db)
f1 := make_test_env_file("/proj1/.env", "sha1", "A=1", []string{"git@github.com:a/repo.git"})
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)
f3 := make_test_env_file("/proj3/.env", "sha3", "C=3")
db_insert(&d, f1)
db_insert(&d, f2)
db_insert(&d, f3)
db_insert(&db, f1)
db_insert(&db, f2)
db_insert(&db, f3)
results, list_ok := db_list(&d)
results, list_ok := db_list(&db)
testing.expect(t, list_ok, "list should succeed")
if !list_ok do return
defer delete(results)
defer {
for &result in results {
delete_envfile(&result)
}
}
testing.expect_value(t, len(results), 3)
}
@(test)
test_db_list_empty :: proc(t: ^testing.T) {
d, ok := make_test_db()
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
defer db_close(&db)
results, list_ok := db_list(&d)
results, list_ok := db_list(&db)
testing.expect(t, list_ok, "list should succeed on empty db")
testing.expect(t, len(results) == 0, "should have 0 rows")
if list_ok do delete(results)
testing.expect_value(t, len(results), 0)
}
@(test)
test_db_insert_sets_changed :: proc(t: ^testing.T) {
d, ok := make_test_db()
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
defer db_close(&db)
testing.expect(t, !d.changed, "changed should start false")
testing.expect(t, !db.changed, "changed should start false")
f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.Remotes)
db_insert(&d, f)
defer delete(f.remotes)
db_insert(&db, f)
testing.expect(t, d.changed, "changed should be true after insert")
testing.expect(t, db.changed, "changed should be true after insert")
}
@(test)
test_db_delete_sets_changed :: proc(t: ^testing.T) {
d, ok := make_test_db()
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
defer db_close(&db)
f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.Remotes)
db_insert(&d, f)
d.changed = false
defer delete(f.remotes)
db_insert(&db, f)
db.changed = false
db_delete(&d, "/project/.env")
testing.expect(t, d.changed, "changed should be true after delete")
db_delete(&db, "/project/.env")
testing.expect(t, db.changed, "changed should be true after delete")
}
@(test)
test_db_serialize :: proc(t: ^testing.T) {
d, ok := make_test_db()
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
if !ok do return
defer sqlite.db_close(d.db)
defer db_close(&db)
f := make_test_env_file("/project/.env", "sha", "KEY=val")
defer delete(f.Remotes)
db_insert(&d, f)
defer delete(f.remotes)
db_insert(&db, f)
sz: i64
data := sqlite.serialize(d.db, "main", &sz, 0)
data := sqlite.serialize(db.conn, "main", &sz, {})
testing.expect(t, data != nil, "serialize should return non-nil")
if data == nil do return
defer sqlite.free(data)
@@ -234,44 +205,13 @@ test_db_serialize :: proc(t: ^testing.T) {
testing.expect(t, sz > 0, "serialized size should be > 0")
}
@(test)
test_db_update_required_noop :: proc(t: ^testing.T) {
testing.expect(t, !db_update_required({}), "Noop should not require update")
}
@(test)
test_db_update_required_backed_up :: proc(t: ^testing.T) {
testing.expect(t, db_update_required({.BackedUp}), "BackedUp should require update")
}
@(test)
test_db_update_required_dir_updated :: proc(t: ^testing.T) {
testing.expect(t, db_update_required({.DirUpdated}), "DirUpdated should require update")
}
@(test)
test_db_update_required_restored :: proc(t: ^testing.T) {
testing.expect(t, !db_update_required({.Restored}), "Restored alone should not require update")
}
@(test)
test_db_update_required_error :: proc(t: ^testing.T) {
testing.expect(t, !db_update_required({.Error}), "Error alone should not require update")
}
@(test)
test_db_update_required_combined :: proc(t: ^testing.T) {
combined := SyncFlag{.DirUpdated, .Restored}
testing.expect(t, db_update_required(combined), "DirUpdated|Restored should require update")
}
@(test)
test_shares_remote_overlap :: proc(t: ^testing.T) {
f := EnvFile {
Remotes = make([dynamic]string, 2, context.temp_allocator),
remotes = make([dynamic]string, 2, context.temp_allocator),
}
append(&f.Remotes, "git@github.com:user/repo.git")
append(&f.Remotes, "git@gitlab.com:user/repo.git")
append(&f.remotes, "git@github.com:user/repo.git")
append(&f.remotes, "git@gitlab.com:user/repo.git")
remotes := []string{"git@github.com:user/repo.git"}
testing.expect(t, shares_remote(&f, remotes), "should share remote")
@@ -280,9 +220,9 @@ test_shares_remote_overlap :: proc(t: ^testing.T) {
@(test)
test_shares_remote_no_overlap :: proc(t: ^testing.T) {
f := EnvFile {
Remotes = make([dynamic]string, 1, context.temp_allocator),
remotes = make([dynamic]string, 1, context.temp_allocator),
}
append(&f.Remotes, "git@github.com:user/repo.git")
append(&f.remotes, "git@github.com:user/repo.git")
remotes := []string{"git@github.com:other/repo.git"}
testing.expect(t, !shares_remote(&f, remotes), "should not share remote")
@@ -291,7 +231,7 @@ test_shares_remote_no_overlap :: proc(t: ^testing.T) {
@(test)
test_shares_remote_empty_file_remotes :: proc(t: ^testing.T) {
f := EnvFile {
Remotes = make([dynamic]string, 0, context.temp_allocator),
remotes = make([dynamic]string, 0, context.temp_allocator),
}
remotes := []string{"git@github.com:user/repo.git"}
@@ -301,9 +241,9 @@ test_shares_remote_empty_file_remotes :: proc(t: ^testing.T) {
@(test)
test_shares_remote_empty_check_remotes :: proc(t: ^testing.T) {
f := EnvFile {
Remotes = make([dynamic]string, 1, context.temp_allocator),
remotes = make([dynamic]string, 1, context.temp_allocator),
}
append(&f.Remotes, "git@github.com:user/repo.git")
append(&f.remotes, "git@github.com:user/repo.git")
remotes: []string
testing.expect(t, !shares_remote(&f, remotes), "empty check remotes should not share")
@@ -312,41 +252,107 @@ test_shares_remote_empty_check_remotes :: proc(t: ^testing.T) {
@(test)
test_shares_remote_both_empty :: proc(t: ^testing.T) {
f := EnvFile {
Remotes = make([dynamic]string, 0),
remotes = make([dynamic]string, 0),
}
remotes: []string
testing.expect(t, !shares_remote(&f, remotes), "both empty should not share")
}
delete_remotes :: proc(remotes: [dynamic]string) {
for &r in remotes {
delete(r)
}
delete(remotes)
}
@(test)
test_make_temp_path_format :: proc(t: ^testing.T) {
p := make_temp_path()
testing.expect(t, strings.has_suffix(p, ".db"), "should end with .db")
testing.expect(t, strings.contains(p, fmt.tprintf("%d", os.get_pid())), "should contain PID")
test_get_git_remotes_single :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-remotes-*")
defer os.remove_all(base)
git_dir := fmt.tprintf("%s/.git", base)
os.mkdir_all(git_dir)
config_content := "[core]\n\trepositoryformatversion = 0\n[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n"
config_path := fmt.tprintf("%s/config", git_dir)
err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect_value(t, err, nil)
remotes := get_git_remotes(base, context.temp_allocator)
testing.expect_value(t, len(remotes), 1)
if len(remotes) != 1 do return
testing.expect_value(t, remotes[0], "git@github.com:user/repo.git")
}
@(test)
test_get_git_remotes_multiple :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-remotes-multi-*")
defer os.remove_all(base)
git_dir := fmt.tprintf("%s/.git", base)
os.mkdir_all(git_dir)
config_content := "[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n[remote \"upstream\"]\n\turl = https://gitlab.com/upstream/repo.git\n"
config_path := fmt.tprintf("%s/config", git_dir)
err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect_value(t, err, nil)
remotes := get_git_remotes(base, context.temp_allocator)
testing.expect_value(t, len(remotes), 2)
}
@(test)
test_get_git_remotes_no_config :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-remotes-none-*")
defer os.remove_all(base)
remotes := get_git_remotes(base, context.temp_allocator)
testing.expect_value(t, len(remotes), 0)
}
@(test)
test_get_git_remotes_no_remotes :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-remotes-empty-*")
defer os.remove_all(base)
git_dir := fmt.tprintf("%s/.git", base)
os.mkdir_all(git_dir)
config_content := "[core]\n\trepositoryformatversion = 0\n\tbare = false\n"
config_path := fmt.tprintf("%s/config", git_dir)
err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect_value(t, err, nil)
remotes := get_git_remotes(base, context.temp_allocator)
testing.expect_value(t, len(remotes), 0)
}
@(test)
test_new_env_file :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-envfile-%d", os.get_pid())
os.mkdir_all(base)
base := test_temp_dir(t, "envr-test-envfile-*")
defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base)
err := os.write_entire_file(env_path, "SECRET=value\n")
testing.expect(t, err == nil, ".env file should exists")
testing.expect_value(t, err, nil)
file, ok := new_env_file(env_path)
testing.expect(t, ok, "new_env_file should succeed")
if !ok do return
defer delete(file.Remotes)
defer delete(file.Sha256)
defer delete(file.Path)
defer delete(file.contents)
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, strings.has_suffix(file.Path, "/.env"), "path should end with /.env")
testing.expect(t, file.contents == "SECRET=value\n", "contents mismatch")
testing.expect(t, len(file.Sha256) == 64, "sha256 should be 64 hex chars")
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_value(t, file.contents, "SECRET=value\n")
testing.expect_value(t, len(file.sha256), 64)
}
@(test)
@@ -356,45 +362,267 @@ test_new_env_file_missing :: proc(t: ^testing.T) {
}
@(test)
test_env_file_backup :: proc(t: ^testing.T) {
base := fmt.tprintf("/tmp/envr-test-backup-%d", os.get_pid())
os.mkdir_all(base)
test_closing_db_has_no_leaks :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-leak-*")
defer os.remove_all(base)
cfg_path, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect_value(t, err, nil)
{
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
testing.expect(t, save_config(cfg, force = true), "save should succeed")
delete_config(&cfg)
}
db, ok := db_open(cfg_path)
testing.expect(t, ok, "db should open")
db_close(&db)
}
@(test)
test_open_existing_db_has_no_leaks :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-leak-existing-*")
defer os.remove_all(base)
cfg_path, err := filepath.join([]string{base, "config.json"}, context.temp_allocator)
testing.expect_value(t, err, nil)
{
cfg := new_config([]string{"fixtures/keys/insecure-test-key"}, cfg_path)
testing.expect(t, save_config(cfg, force = true), "save should succeed")
delete_config(&cfg)
}
// First open/close creates data.envr on disk
db, ok := db_open(cfg_path)
testing.expect(t, ok, "db should open")
if !ok do return
f := make_test_env_file(
"/project/.env",
"abc123",
"SECRET=value",
[]string{"git@github.com:user/repo.git"},
)
defer delete(f.remotes)
testing.expect(t, db_insert(&db, f), "insert should succeed")
db_close(&db)
// Second open exercises db_restore_from_encrypted
db2, ok2 := db_open(cfg_path)
testing.expect(t, ok2, "db should open existing")
if !ok2 do return
db_close(&db2)
}
@(test)
test_db_sync_noop :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-sync-noop-*")
defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base)
err := os.write_entire_file(env_path, "KEY=12345\n")
testing.expect(t, err == nil, ".env file should exist")
content := "KEY=value\n"
write_err := os.write_entire_file(env_path, transmute([]u8)content)
testing.expect_value(t, write_err, nil)
f := EnvFile {
Path = env_path,
}
defer delete(f.contents)
defer delete(f.Sha256)
testing.expect(t, env_file_backup(&f), "backup should succeed")
testing.expect_value(t, f.contents, "KEY=12345\n")
testing.expect_value(t, len(f.Sha256), 64)
digest := hash.hash_bytes(
hash.Algorithm.SHA256,
transmute([]u8)content,
context.temp_allocator,
)
hex_bytes := hex.encode(digest, context.temp_allocator)
sha := string(hex_bytes)
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&db)
f := make_test_env_file(env_path, sha, content)
f.dir = base
db_insert(&db, f)
result, sync_err := db_sync(&db, &f)
testing.expect_value(t, sync_err, SyncError.None)
testing.expect_value(t, result, nil)
}
@(test)
test_env_file_backup_missing :: proc(t: ^testing.T) {
f := EnvFile {
Path = "/tmp/envr-nonexistent-backup/.env",
}
testing.expect(t, !env_file_backup(&f), "missing file should return false")
test_db_sync_backed_up :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-sync-backup-*")
defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base)
changed_content := "KEY=changed\n"
write_err := os.write_entire_file(env_path, transmute([]u8)changed_content)
testing.expect_value(t, write_err, nil)
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&db)
f := make_test_env_file(env_path, "old_sha", "KEY=original")
f.dir = base
db_insert(&db, f)
result, sync_err := db_sync(&db, &f)
testing.expect_value(t, sync_err, SyncError.None)
testing.expect(t, .BackedUp in result, "should be backed up")
}
@(test)
test_update_dir :: proc(t: ^testing.T) {
f := EnvFile {
Path = "/old/project/.env",
Dir = "/old/project",
Remotes = make([dynamic]string, 0),
test_db_sync_restored :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-sync-restore-*")
defer os.remove_all(base)
env_path := fmt.tprintf("%s/.env", base)
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&db)
f := make_test_env_file(env_path, "some_sha", "SECRET=value")
f.dir = base
defer delete(f.remotes)
db_insert(&db, f)
result, err := db_sync(&db, &f)
testing.expect_value(t, err, SyncError.None)
testing.expect(t, .Restored in result, "should be restored")
data, read_err := os.read_entire_file_from_path(env_path, context.temp_allocator)
testing.expect_value(t, read_err, nil)
if read_err == nil {
testing.expect_value(t, string(data), "SECRET=value")
}
defer delete_envfile(&f)
}
update_dir(&f, "/new/location")
@(test)
test_db_sync_dir_missing :: proc(t: ^testing.T) {
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&db)
testing.expect_value(t, f.Dir, "/new/location")
testing.expect_value(t, f.Path, "/new/location/.env")
f := make_test_env_file("/nonexistent/path/.env", "sha", "KEY=val")
db_insert(&db, f)
result, err := db_sync(&db, &f)
testing.expect_value(t, err, SyncError.DirMissing)
testing.expect_value(t, result, nil)
}
@(test)
test_db_sync_moved :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-sync-moved-*")
search_root := fmt.tprintf("%s/search", base)
repo_dir := fmt.tprintf("%s/myproject", search_root)
git_dir := fmt.tprintf("%s/.git", repo_dir)
defer os.remove_all(base)
os.mkdir_all(git_dir)
config_content := "[remote \"origin\"]\n\turl = git@github.com:user/repo.git\n"
config_path := fmt.tprintf("%s/config", git_dir)
write_err := os.write_entire_file(config_path, transmute([]u8)config_content)
testing.expect_value(t, write_err, nil)
db, ok := db_init()
testing.expect(t, ok, "failed to create test db")
defer db_close(&db)
db.cfg.scan_config.include = make([dynamic]string, 0, 1, context.temp_allocator)
append(&db.cfg.scan_config.include, search_root)
f := make_test_env_file(
"/old/nonexistent/path/.env",
"some_sha",
"SECRET=value",
[]string{"git@github.com:user/repo.git"},
)
testing.expect(t, db_insert(&db, f), "insert should succeed")
result, err := db_sync(&db, &f)
testing.expect_value(t, err, SyncError.None)
if err != .None do return
testing.expect(t, .DirUpdated in result, "should have DirUpdated flag")
testing.expect(t, .Restored in result, "should have Restored flag")
expected_path := fmt.tprintf("%s/.env", repo_dir)
testing.expect_value(t, f.path, expected_path)
testing.expect_value(t, f.dir, repo_dir)
_, old_exists := db_fetch(&db, "/old/nonexistent/path/.env")
testing.expect(t, !old_exists, "old path should be deleted from db")
new_fetched, new_ok := db_fetch(&db, expected_path)
testing.expect(t, new_ok, "new path should exist in db")
if new_ok {
testing.expect_value(t, new_fetched.contents, "SECRET=value")
}
}
@(test)
test_db_close_preserves_inodes :: proc(t: ^testing.T) {
base := test_temp_dir(t, "envr-test-inode-*")
defer os.remove_all(base)
envr_dir, _ := filepath.join([]string{base, ".envr"}, context.temp_allocator)
config_path, _ := filepath.join([]string{envr_dir, "config.json"}, context.temp_allocator)
os.mkdir_all(envr_dir)
key_abs, _ := filepath.abs("fixtures/keys/insecure-test-key", context.temp_allocator)
cfg := new_config([]string{key_abs}, config_path)
defer delete_config(&cfg)
testing.expect(t, save_config(cfg, force = true), "save_config should succeed")
// Create database
{
db, ok := db_open(config_path)
defer db_close(&db)
testing.expect(t, ok, "first db_open should succeed")
if !ok do return
env_path, _ := filepath.join([]string{base, ".env"}, context.temp_allocator)
f := make_test_env_file(env_path, "sha", "KEY=value")
db_insert(&db, f)
}
// Record inode of data.envr
data_file, _ := data_path(config_path, context.temp_allocator)
cdata := strings.clone_to_cstring(data_file)
defer delete(cdata)
sb1: posix.stat_t
testing.expect(t, posix.stat(cdata, &sb1) == .OK, "stat should succeed")
inode_before := sb1.st_ino
// Create hardlink
link_path, _ := filepath.join([]string{envr_dir, "data.envr.link"}, context.temp_allocator)
clink := strings.clone_to_cstring(link_path)
defer delete(clink)
testing.expect(t, posix.link(cdata, clink) == .OK, "hardlink should succeed")
// Update database
{
db, ok2 := db_open(config_path)
defer db_close(&db)
testing.expect(t, ok2, "second db_open should succeed")
if !ok2 do return
env_path2, _ := filepath.join([]string{base, ".env2"}, context.temp_allocator)
f2 := make_test_env_file(env_path2, "sha2", "KEY2=value2")
db_insert(&db, f2)
}
// Verify inode preserved
sb2: posix.stat_t
testing.expect(t, posix.stat(cdata, &sb2) == .OK, "stat should succeed after rewrite")
testing.expect(t, sb2.st_ino == inode_before, "data.envr inode should be unchanged")
// Verify hardlink still valid
sb_link: posix.stat_t
testing.expect(t, posix.stat(clink, &sb_link) == .OK, "stat on hardlink should succeed")
testing.expect(t, sb_link.st_ino == inode_before, "hardlink inode should match")
testing.expect(t, sb_link.st_nlink == 2, "hardlink count should still be 2")
}
+13 -13
View File
@@ -4,11 +4,11 @@ Manage your .env files.
### Synopsis
envr keeps your .env synced to a local, age encrypted database.
Is a safe and eay way to gather all your .env files in one place where they can
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
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:
@@ -33,26 +33,26 @@ Select the files you want to back up from the interactive list.
5. If you lose a repository, after re-cloning the repo into the same path it was
at before, restore your backup with:
> envr restore ~/&lt;path to repository&gt;/.env
> envr restore ~/<path to repository>/.env
### Options
```
-h, --help help for envr
-h, --help show this documentation
```
### SEE ALSO
* [envr backup](envr_backup.md) - Import a .env file into envr
* [envr check](envr_check.md) - check if files in the current directory are backed up
* [envr deps](envr_deps.md) - Check for missing binaries
* [envr edit-config](envr_edit-config.md) - Edit your config with your default editor
* [envr init](envr_init.md) - Set up envr
* [envr list](envr_list.md) - View your tracked files
* [envr nushell-completion](envr_nushell-completion.md) - Generate custom completions for nushell
* [envr remove](envr_remove.md) - Remove a .env file from your database
* [envr restore](envr_restore.md) - Install a .env file from the database into your file system
* [envr scan](envr_scan.md) - Find and select .env files for backup
* [envr sync](envr_sync.md) - Update or restore your env backups
* [envr backup](envr_backup.md) - Import a .env file into envr
* [envr restore](envr_restore.md) - Restore a .env file from the database
* [envr list](envr_list.md) - View your tracked files
* [envr remove](envr_remove.md) - Remove a .env file from your database
* [envr check](envr_check.md) - Check if files are backed up
* [envr version](envr_version.md) - Show envr's version
* [envr edit-config](envr_edit-config.md) - Edit your config with your default editor
* [envr completion](envr_completion.md) - Generate shell completion scripts
* [envr uninstall](envr_uninstall.md) - Remove envr configuration and database
+9 -2
View File
@@ -6,13 +6,20 @@ Import a .env file into envr
envr backup <path> [flags]
```
**Arguments:**
* `path` — Path to .env file to backup
### Options
```
-h, --help help for backup
-h, --help show this documentation
-c, --config-file config file (default "~/.envr/config.json")
--color Whether or not to colorize output (default 'auto')
```
*Alias: `add`*
### SEE ALSO
* [envr](envr.md) - Manage your .env files.
+8 -3
View File
@@ -1,18 +1,23 @@
## envr check
check if files in the current directory are backed up
Check if files are backed up
```
envr check [path] [flags]
```
**Arguments:**
* `path` — Path to check (defaults to current directory) (optional)
### Options
```
-h, --help help for check
-h, --help show this documentation
-c, --config-file config file (default "~/.envr/config.json")
--color Whether or not to colorize output (default 'auto')
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.
+28
View File
@@ -0,0 +1,28 @@
## envr completion
Generate shell completion scripts
### Synopsis
Supported shells:
nushell
bash
```
envr completion <shell> [flags]
```
**Arguments:**
* `shell` — Shell to generate completions for
### Options
```
-h, --help show this documentation
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.
-24
View File
@@ -1,24 +0,0 @@
## envr deps
Check for missing binaries
### Synopsis
envr relies on external binaries for certain functionality.
The check command reports on which binaries are available and which are not.
```
envr deps [flags]
```
### Options
```
-h, --help help for deps
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.
+3 -2
View File
@@ -9,10 +9,11 @@ envr edit-config [flags]
### Options
```
-h, --help help for edit-config
-h, --help show this documentation
-c, --config-file config file (default "~/.envr/config.json")
--color Whether or not to colorize output (default 'auto')
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.
+5 -6
View File
@@ -5,9 +5,7 @@ Set up envr
### Synopsis
The init command generates your initial config and saves it to
~/.envr/config in JSON format.
During setup, you will be prompted to select one or more ssh keys with which 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.
@@ -18,11 +16,12 @@ envr init [flags]
### Options
```
-f, --force Overwrite an existing config
-h, --help help for init
-h, --help show this documentation
-c, --config-file config file (default "~/.envr/config.json")
--color Whether or not to colorize output (default 'auto')
-f, --force Overwrite existing config
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.
+4 -2
View File
@@ -9,10 +9,12 @@ envr list [flags]
### Options
```
-h, --help help for list
-h, --help show this documentation
-c, --config-file config file (default "~/.envr/config.json")
-o, --output the format of output data (default 'table')
--color Whether or not to colorize output (default 'auto')
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.
-23
View File
@@ -1,23 +0,0 @@
## envr nushell-completion
Generate custom completions for nushell
### Synopsis
At time of writing, cobra does not natively support nushell,
so a custom command had to be written
```
envr nushell-completion [flags]
```
### Options
```
-h, --help help for nushell-completion
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.
+8 -3
View File
@@ -3,16 +3,21 @@
Remove a .env file from your database
```
envr remove [flags]
envr remove <path> [flags]
```
**Arguments:**
* `path` — Path to .env file to remove
### Options
```
-h, --help help for remove
-h, --help show this documentation
-c, --config-file config file (default "~/.envr/config.json")
--color Whether or not to colorize output (default 'auto')
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.
+9 -4
View File
@@ -1,18 +1,23 @@
## envr restore
Install a .env file from the database into your file system
Restore a .env file from the database
```
envr restore [flags]
envr restore <path> [flags]
```
**Arguments:**
* `path` — Path to .env file to restore
### Options
```
-h, --help help for restore
-h, --help show this documentation
-c, --config-file config file (default "~/.envr/config.json")
--color Whether or not to colorize output (default 'auto')
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.
+3 -2
View File
@@ -9,10 +9,11 @@ envr scan [flags]
### Options
```
-h, --help help for scan
-h, --help show this documentation
-c, --config-file config file (default "~/.envr/config.json")
--color Whether or not to colorize output (default 'auto')
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.
+4 -2
View File
@@ -9,10 +9,12 @@ envr sync [flags]
### Options
```
-h, --help help for sync
-h, --help show this documentation
-c, --config-file config file (default "~/.envr/config.json")
-o, --output the format of output data (default 'table')
--color Whether or not to colorize output (default 'auto')
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.
+24
View File
@@ -0,0 +1,24 @@
## envr uninstall
Remove envr configuration and database
### Synopsis
This will remove all files generated by envr from your system.
```
envr uninstall [flags]
```
### Options
```
-h, --help show this documentation
-c, --config-file config file (default "~/.envr/config.json")
--color Whether or not to colorize output (default 'auto')
-f, --force Overwrite existing config
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.
+1 -3
View File
@@ -9,11 +9,9 @@ envr version [flags]
### Options
```
-h, --help help for version
-l, --long Show all version information
-h, --help show this documentation
```
### SEE ALSO
* [envr](envr.md) - Manage your .env files.
-51
View File
@@ -1,51 +0,0 @@
package main
import "base:runtime"
import "core:mem"
import "core:os"
import "core:strings"
Feature :: enum {
Git,
Fd,
}
AvailableFeatures :: bit_set[Feature]
check_features :: proc() -> AvailableFeatures {
feats: AvailableFeatures
s: mem.Scratch
mem.scratch_init(&s, 4 * mem.DEFAULT_PAGE_SIZE)
defer mem.scratch_destroy(&s)
context.temp_allocator = mem.scratch_allocator(&s)
path_env := os.get_env("PATH", context.temp_allocator)
paths := strings.split(path_env, ":", context.temp_allocator)
if find_binary(paths, "git") != "" {
feats += {.Git}
}
if find_binary(paths, "fd") != "" {
feats += {.Fd}
}
return feats
}
find_binary :: proc(
paths: []string,
name: string,
allocator: runtime.Allocator = context.temp_allocator,
) -> string {
for p in paths {
candidate := strings.join({strings.trim_right(p, "/"), name}, "/", allocator)
_, err := os.stat(candidate, allocator)
if err == nil {
return candidate
}
}
return ""
}
-34
View File
@@ -1,34 +0,0 @@
package main
import "core:os"
import "core:strings"
import "core:testing"
@(test)
test_find_binary_exists :: proc(t: ^testing.T) {
path := os.get_env("PATH", context.temp_allocator)
paths := strings.split(path, ":", context.temp_allocator)
result := find_binary(paths, "sh")
testing.expect(t, result != "", "sh should be found on PATH")
}
@(test)
test_find_binary_not_exists :: proc(t: ^testing.T) {
old_path := os.get_env("PATH", context.temp_allocator)
defer {
if old_path != "" {
os.set_env("PATH", old_path)
}
}
os.set_env("PATH", "/tmp/envr-nope")
path := os.get_env("PATH", context.temp_allocator)
paths := strings.split(path, ":", context.temp_allocator)
result := find_binary(paths, "no_such_binary_xyz")
testing.expect(t, result == "", "nonexistent binary should not be found")
}
+299
View File
@@ -0,0 +1,299 @@
package findr
import "core:os"
import "core:sort"
import "core:strings"
import "core:sys/linux"
import "core:testing"
// ============================================================================
// Gitignored file emission tests (emit ONLY gitignored files, descend everywhere)
// ============================================================================
@(test)
test_basic_gitignored :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "*.env\n")
create_file(env, "repo/.env")
create_file(env, "repo/secrets.env")
create_file(env, "repo/normal.txt")
assert_output(t, env, nil, {}, {"repo/.env", "repo/secrets.env"})
}
@(test)
test_non_repo_not_scanned :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_dir(env, "norepo")
create_file(env, "norepo/.gitignore", "*.env\n")
create_file(env, "norepo/.env")
assert_output_empty(t, env, nil, {})
}
@(test)
test_negation_pattern :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "*.env\n!prod.env\n")
create_file(env, "repo/.env")
create_file(env, "repo/secrets.env")
create_file(env, "repo/prod.env")
assert_output(t, env, nil, {}, {"repo/.env", "repo/secrets.env"})
}
@(test)
test_multiple_repos :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo1")
create_file(env, "repo1/.gitignore", "*.env\n")
create_file(env, "repo1/a.env")
create_git_repo(env, "repo2")
create_file(env, "repo2/.gitignore", "*.key\n")
create_file(env, "repo2/secret.key")
assert_output(t, env, nil, {}, {"repo1/a.env", "repo2/secret.key"})
}
@(test)
test_nested_repos :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "parent")
create_file(env, "parent/.gitignore", "*.env\n")
create_file(env, "parent/top.env")
create_git_repo(env, "parent/child")
create_file(env, "parent/child/.gitignore", "*.key\n")
create_file(env, "parent/child/api.key")
assert_output(t, env, nil, {}, {"parent/top.env", "parent/child/api.key"})
}
@(test)
test_nested_gitignore_read :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "*.env\n")
create_dir(env, "repo/sub")
create_file(env, "repo/sub/.gitignore", "*.txt\n")
create_file(env, "repo/sub/secret.txt")
create_file(env, "repo/sub/.env")
assert_output(t, env, nil, {}, {"repo/sub/secret.txt", "repo/sub/.env"})
}
@(test)
test_nested_gitignore_negation :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "*.log\n")
create_dir(env, "repo/sub")
create_file(env, "repo/sub/.gitignore", "!important.log\n")
create_file(env, "repo/sub/important.log")
create_file(env, "repo/sub/debug.log")
assert_output(t, env, nil, {}, {"repo/sub/debug.log"})
}
@(test)
test_multisegment_pattern :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "build/output.txt\n")
create_dir(env, "repo/build")
create_file(env, "repo/build/output.txt")
create_file(env, "repo/build/other.txt")
create_file(env, "repo/output.txt")
assert_output(t, env, nil, {}, {"repo/build/output.txt"})
}
@(test)
test_no_gitignore_file :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.env")
assert_output_empty(t, env, nil, {})
}
@(test)
test_empty_gitignore :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "\n\n# comment\n\n")
create_file(env, "repo/.env")
assert_output_empty(t, env, nil, {})
}
@(test)
test_multiple_search_dirs :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "dir1/repo")
create_file(env, "dir1/repo/.gitignore", "*.env\n")
create_file(env, "dir1/repo/a.env")
create_file(env, "dir1/repo/normal.txt")
create_git_repo(env, "dir2/repo")
create_file(env, "dir2/repo/.gitignore", "*.env\n")
create_file(env, "dir2/repo/b.env")
dir1 := join_path(env.temp_dir, "dir1")
defer delete(dir1)
dir2 := join_path(env.temp_dir, "dir2")
defer delete(dir2)
results := make([dynamic]string)
defer {
for r in results {delete(r)}
delete(results)
}
opts := WalkOptions{}
thread_count := os.get_processor_core_count()
walk({dir1, dir2}, &results, opts, thread_count)
testing.expect_value(t, len(results), 2)
actual := make([dynamic]string, 0, len(results))
for r in results {
stripped := r
if strings.has_prefix(stripped, env.temp_dir) {
stripped = stripped[len(env.temp_dir):]
if len(stripped) > 0 && stripped[0] == os.Path_Separator {
stripped = stripped[1:]
}
}
append(&actual, stripped)
}
defer delete(actual)
expected := []string{"dir1/repo/a.env", "dir2/repo/b.env"}
sort.quick_sort(actual[:])
sort.quick_sort(expected[:])
for i in 0 ..< len(expected) {
testing.expect_value(t, actual[i], expected[i])
}
}
// ============================================================================
// Ignored directory recursion tests
// ============================================================================
@(test)
test_ignored_dir_descended :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "secrets/\n")
create_dir(env, "repo/secrets")
create_file(env, "repo/secrets/.env")
create_file(env, "repo/secrets/api.key")
// Ignored dir's contents are emitted AND descended into
assert_output(t, env, nil, {}, {"repo/secrets/", "repo/secrets/.env", "repo/secrets/api.key"})
}
@(test)
test_nested_ignored_dir :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "build/\n")
create_dir(env, "repo/build")
create_dir(env, "repo/build/sub")
create_file(env, "repo/build/output.txt")
create_file(env, "repo/build/sub/deep.env")
assert_output(
t,
env,
nil,
{},
{"repo/build/", "repo/build/output.txt", "repo/build/sub/", "repo/build/sub/deep.env"},
)
}
// ============================================================================
// Filter tests (excludes, pattern)
// ============================================================================
@(test)
test_excludes_prune_dirs :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "*.env\n")
create_file(env, "repo/.env")
create_dir(env, "repo/vendor")
create_file(env, "repo/vendor/lib.env")
assert_output(t, env, nil, {excludes = {"vendor"}}, {"repo/.env"})
}
@(test)
test_pattern_filters_results :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "*.env\n*.key\n")
create_file(env, "repo/.env")
create_file(env, "repo/secrets.env")
create_file(env, "repo/master.key")
assert_output(t, env, nil, {pattern = "\\.env$"}, {"repo/.env", "repo/secrets.env"})
}
// ============================================================================
// Special file type tests
// ============================================================================
@(test)
test_fifo_emitted :: proc(t: ^testing.T) {
env := create_test_env()
defer destroy_test_env(&env)
create_git_repo(env, "repo")
create_file(env, "repo/.gitignore", "*.env\n*.fifo\n")
fifo_path := join_path(env.temp_dir, "repo/test.fifo")
defer delete(fifo_path)
cpath := strings.clone_to_cstring(fifo_path)
defer delete(cpath)
linux.mknod(cpath, linux.S_IFIFO | linux.Mode{.IRUSR, .IWUSR}, 0)
assert_output(t, env, nil, {pattern = "\\.fifo$"}, {"repo/test.fifo"})
}
+88
View File
@@ -0,0 +1,88 @@
package findr
import "core:strings"
Gitignore :: struct {
rules: [dynamic]Rule,
}
Rule :: struct {
pattern: GlobPattern,
negated: bool,
dir_only: bool,
}
Match :: enum {
None,
Ignored,
Unignored,
}
is_ignored :: proc(gi: ^Gitignore, path: string, is_dir: bool) -> bool {
return check_match(gi, path, is_dir) == .Ignored
}
check_match :: proc(gi: ^Gitignore, path: string, is_dir: bool) -> Match {
result := Match.None
for &rule in gi.rules {
if rule.dir_only && !is_dir do continue
if glob_match_compiled(&rule.pattern, path) {
result = rule.negated ? .Unignored : .Ignored
}
}
return result
}
parse :: proc(content: string) -> Gitignore {
gi: Gitignore
gi.rules = make([dynamic]Rule)
remaining := content
for {
line, ok := strings.split_lines_iterator(&remaining)
if !ok do break
s := strings.trim_space(line)
if len(s) == 0 do continue
if s[0] == '#' do continue
negated := false
if s[0] == '!' {
negated = true
s = s[1:]
}
if len(s) > 0 && s[0] == '\\' {
if len(s) > 1 && (s[1] == '#' || s[1] == '!') {
s = s[1:]
}
}
dir_only := false
if len(s) > 0 && s[len(s) - 1] == '/' {
dir_only = true
s = s[:len(s) - 1]
}
anchored := false
if len(s) > 0 && s[0] == '/' {
anchored = true
s = s[1:]
}
if len(s) == 0 do continue
gp := glob_compile(s, anchored)
append(&gi.rules, Rule{pattern = gp, negated = negated, dir_only = dir_only})
}
return gi
}
destroy :: proc(gi: ^Gitignore) {
for &rule in gi.rules {
glob_destroy(&rule.pattern)
}
delete(gi.rules)
}
+118
View File
@@ -0,0 +1,118 @@
package findr
import "core:testing"
@(test)
test_is_ignored_basic :: proc(t: ^testing.T) {
gi := parse("*.env\n")
defer destroy(&gi)
testing.expect_value(t, is_ignored(&gi, ".env", false), true)
testing.expect_value(t, is_ignored(&gi, "foo.env", false), true)
testing.expect_value(t, is_ignored(&gi, ".env.local", false), false)
testing.expect_value(t, is_ignored(&gi, "config.yaml", false), false)
}
@(test)
test_is_ignored_negation :: proc(t: ^testing.T) {
gi := parse("*.env\n!.env.production\n")
defer destroy(&gi)
testing.expect_value(t, is_ignored(&gi, ".env", false), true)
testing.expect_value(t, is_ignored(&gi, ".env.production", false), false)
}
@(test)
test_is_ignored_dir_only :: proc(t: ^testing.T) {
gi := parse("node_modules/\n")
defer destroy(&gi)
testing.expect_value(t, is_ignored(&gi, "node_modules", true), true)
testing.expect_value(t, is_ignored(&gi, "node_modules", false), false)
}
@(test)
test_is_ignored_anchored :: proc(t: ^testing.T) {
gi := parse("/secret.key\n")
defer destroy(&gi)
testing.expect_value(t, is_ignored(&gi, "secret.key", false), true)
}
@(test)
test_is_ignored_comments_skipped :: proc(t: ^testing.T) {
gi := parse("# this is a comment\n#another\n*.tmp\n")
defer destroy(&gi)
testing.expect_value(t, len(gi.rules), 1)
testing.expect_value(t, is_ignored(&gi, "file.tmp", false), true)
}
@(test)
test_is_ignored_blank_lines_skipped :: proc(t: ^testing.T) {
gi := parse("\n\n \n*.log\n\n")
defer destroy(&gi)
testing.expect_value(t, len(gi.rules), 1)
}
@(test)
test_is_ignored_last_match_wins :: proc(t: ^testing.T) {
gi := parse("*.env\n!*.env\n")
defer destroy(&gi)
testing.expect_value(t, is_ignored(&gi, ".env", false), false)
}
@(test)
test_is_ignored_no_rules :: proc(t: ^testing.T) {
gi := parse("")
defer destroy(&gi)
testing.expect_value(t, is_ignored(&gi, "anything", false), false)
}
@(test)
test_is_ignored_env_pattern :: proc(t: ^testing.T) {
gi := parse(".env*\n")
defer destroy(&gi)
testing.expect_value(t, is_ignored(&gi, ".env", false), true)
testing.expect_value(t, is_ignored(&gi, ".env.local", false), true)
testing.expect_value(t, is_ignored(&gi, ".envrc", false), true)
}
@(test)
test_is_ignored_globstar :: proc(t: ^testing.T) {
gi := parse("**/cache\n")
defer destroy(&gi)
testing.expect_value(t, is_ignored(&gi, "cache", false), true)
testing.expect_value(t, is_ignored(&gi, "foo/cache", false), true)
testing.expect_value(t, is_ignored(&gi, "foo/bar/cache", false), true)
}
@(test)
test_star_negation_subpath :: proc(t: ^testing.T) {
gi := parse("*\n!public/\n")
defer destroy(&gi)
// public dir itself is un-ignored
testing.expect_value(t, is_ignored(&gi, "public", true), false)
// children of public/ should still be ignored by *
testing.expect_value(t, is_ignored(&gi, "public/uuid-dir", true), true)
testing.expect_value(t, is_ignored(&gi, "public/uuid-dir/file.txt", false), true)
}
@(test)
test_is_ignored_hash_pattern :: proc(t: ^testing.T) {
gi := parse("\\#*\\#\n")
defer destroy(&gi)
testing.expect_value(t, is_ignored(&gi, "#foo#", false), true)
testing.expect_value(t, is_ignored(&gi, "#test#", false), true)
testing.expect_value(t, is_ignored(&gi, "AUTHORS", false), false)
testing.expect_value(t, is_ignored(&gi, "build.zig", false), false)
testing.expect_value(t, is_ignored(&gi, "ChangeLog", false), false)
}
+203
View File
@@ -0,0 +1,203 @@
package findr
Range :: struct {
lo: u8,
hi: u8,
}
Class_Data :: struct {
negated: bool,
ranges: [dynamic]Range,
}
Token_Kind :: enum u8 { Char, Star, Globstar, Question, Class }
Token :: struct {
kind: Token_Kind,
byte: u8,
class_idx: u16,
}
GlobPattern :: struct {
tokens: [dynamic]Token,
classes: [dynamic]Class_Data,
anchored: bool,
}
glob_compile :: proc(pattern: string, anchored: bool) -> GlobPattern {
gp: GlobPattern
gp.tokens = make([dynamic]Token)
gp.classes = make([dynamic]Class_Data)
gp.anchored = anchored
i := 0
for i < len(pattern) {
c := pattern[i]
if c == '*' {
if i + 1 < len(pattern) && pattern[i + 1] == '*' {
prev_slash := i == 0 || pattern[i - 1] == '/'
at_end := i + 2 >= len(pattern)
next_slash := !at_end && pattern[i + 2] == '/'
if prev_slash && (next_slash || at_end) {
append(&gp.tokens, Token{kind = .Globstar})
if next_slash {
i += 3
} else {
i += 2
}
} else {
append(&gp.tokens, Token{kind = .Star})
i += 2
}
} else {
append(&gp.tokens, Token{kind = .Star})
i += 1
}
} else if c == '?' {
append(&gp.tokens, Token{kind = .Question})
i += 1
} else if c == '[' {
i += 1
negated := false
if i < len(pattern) && pattern[i] == '!' {
negated = true
i += 1
}
ranges := make([dynamic]Range)
if i < len(pattern) && pattern[i] == ']' {
append(&ranges, Range{lo = ']', hi = ']'})
i += 1
}
for i < len(pattern) && pattern[i] != ']' {
if i + 2 < len(pattern) && pattern[i + 1] == '-' && pattern[i + 2] != ']' {
append(&ranges, Range{lo = pattern[i], hi = pattern[i + 2]})
i += 3
} else {
append(&ranges, Range{lo = pattern[i], hi = pattern[i]})
i += 1
}
}
if i < len(pattern) {
i += 1
}
class_idx := u16(len(gp.classes))
append(&gp.classes, Class_Data{negated = negated, ranges = ranges})
append(&gp.tokens, Token{kind = .Class, class_idx = class_idx})
} else if c == '\\' {
i += 1
if i < len(pattern) {
append(&gp.tokens, Token{kind = .Char, byte = pattern[i]})
i += 1
}
} else {
append(&gp.tokens, Token{kind = .Char, byte = c})
i += 1
}
}
return gp
}
match_tokens :: proc(tokens: []Token, classes: []Class_Data, ti: int, path: string, pi: int) -> bool {
if ti >= len(tokens) {
return pi == len(path)
}
tok := tokens[ti]
switch tok.kind {
case .Char:
if pi < len(path) && path[pi] == tok.byte {
return match_tokens(tokens, classes, ti + 1, path, pi + 1)
}
return false
case .Question:
if pi < len(path) && path[pi] != '/' {
return match_tokens(tokens, classes, ti + 1, path, pi + 1)
}
return false
case .Star:
max_end := pi
for max_end < len(path) && path[max_end] != '/' {
max_end += 1
}
for end := max_end; end >= pi; end -= 1 {
if match_tokens(tokens, classes, ti + 1, path, end) {
return true
}
}
return false
case .Globstar:
if ti + 1 >= len(tokens) {
return true
}
if match_tokens(tokens, classes, ti + 1, path, pi) {
return true
}
for end := pi + 1; end <= len(path); end += 1 {
if path[end - 1] == '/' {
if match_tokens(tokens, classes, ti + 1, path, end) {
return true
}
}
}
return false
case .Class:
if pi >= len(path) {
return false
}
cd := classes[tok.class_idx]
ch := path[pi]
in_range := false
for r in cd.ranges {
if ch >= r.lo && ch <= r.hi {
in_range = true
break
}
}
if in_range != cd.negated {
return match_tokens(tokens, classes, ti + 1, path, pi + 1)
}
return false
}
return false
}
glob_match_compiled :: proc(gp: ^GlobPattern, path: string) -> bool {
tokens := gp.tokens[:]
classes := gp.classes[:]
if gp.anchored {
return match_tokens(tokens, classes, 0, path, 0)
}
if match_tokens(tokens, classes, 0, path, 0) {
return true
}
for i := 1; i < len(path); i += 1 {
if path[i - 1] == '/' {
if match_tokens(tokens, classes, 0, path, i) {
return true
}
}
}
return false
}
glob_destroy :: proc(gp: ^GlobPattern) {
for &cd in gp.classes {
delete(cd.ranges)
}
delete(gp.classes)
delete(gp.tokens)
}
+111
View File
@@ -0,0 +1,111 @@
package findr
import "core:testing"
glob_match :: proc(pattern: string, path: string, anchored: bool) -> bool {
gp := glob_compile(pattern, anchored)
result := glob_match_compiled(&gp, path)
glob_destroy(&gp)
return result
}
@(test)
test_glob_simple :: proc(t: ^testing.T) {
testing.expect(t, glob_match("foo", "foo", false))
testing.expect(t, glob_match("foo", "bar/foo", false))
testing.expect(t, !glob_match("foo", "foobar", false))
testing.expect(t, !glob_match("foo", "foo/bar", false))
}
@(test)
test_glob_anchored :: proc(t: ^testing.T) {
testing.expect(t, glob_match("foo", "foo", true))
testing.expect(t, !glob_match("foo", "bar/foo", true))
testing.expect(t, !glob_match("foo", "foobar", true))
}
@(test)
test_glob_star :: proc(t: ^testing.T) {
testing.expect(t, glob_match("*.log", "test.log", false))
testing.expect(t, glob_match("*.log", ".log", false))
testing.expect(t, !glob_match("*.log", "test.txt", false))
testing.expect(t, !glob_match("*.log", "dir/test", false))
}
@(test)
test_glob_question :: proc(t: ^testing.T) {
testing.expect(t, glob_match("?.log", "a.log", false))
testing.expect(t, !glob_match("?.log", "ab.log", false))
testing.expect(t, !glob_match("?.log", ".log", false))
}
@(test)
test_glob_char_class :: proc(t: ^testing.T) {
testing.expect(t, glob_match("[abc].log", "a.log", false))
testing.expect(t, glob_match("[abc].log", "b.log", false))
testing.expect(t, !glob_match("[abc].log", "d.log", false))
}
@(test)
test_glob_negated_class :: proc(t: ^testing.T) {
testing.expect(t, glob_match("[!abc].log", "d.log", false))
testing.expect(t, !glob_match("[!abc].log", "a.log", false))
}
@(test)
test_glob_dot_literal :: proc(t: ^testing.T) {
testing.expect(t, glob_match(".env", ".env", false))
testing.expect(t, glob_match(".env", "dir/.env", false))
testing.expect(t, !glob_match(".env", "env", false))
testing.expect(t, !glob_match(".env", "x.env", false))
}
@(test)
test_glob_globstar_prefix :: proc(t: ^testing.T) {
testing.expect(t, glob_match("**/foo", "foo", false))
testing.expect(t, glob_match("**/foo", "a/b/foo", false))
testing.expect(t, !glob_match("**/foo", "foobar", false))
testing.expect(t, !glob_match("**/foo", "a/foobar", false))
}
@(test)
test_glob_globstar_suffix :: proc(t: ^testing.T) {
testing.expect(t, glob_match("abc/**", "abc/x", false))
testing.expect(t, glob_match("abc/**", "abc/x/y", false))
testing.expect(t, !glob_match("abc/**", "abc", false))
testing.expect(t, !glob_match("abc/**", "abcd/x", false))
}
@(test)
test_glob_globstar_middle :: proc(t: ^testing.T) {
testing.expect(t, glob_match("foo/**/bar", "foo/bar", false))
testing.expect(t, glob_match("foo/**/bar", "foo/x/bar", false))
testing.expect(t, !glob_match("foo/**/bar", "foo/barx", false))
testing.expect(t, !glob_match("foo/**/bar", "foo/x/y/baz", false))
}
@(test)
test_glob_backslash_escape :: proc(t: ^testing.T) {
testing.expect(t, glob_match("\\!foo", "!foo", false))
testing.expect(t, !glob_match("\\!foo", "foo", false))
}
@(test)
test_glob_hash_literal :: proc(t: ^testing.T) {
testing.expect(t, glob_match("#foo", "#foo", false))
testing.expect(t, !glob_match("#foo", "foo", false))
}
@(test)
test_glob_hash_pattern :: proc(t: ^testing.T) {
testing.expect(t, glob_match("#*#", "#test#", false))
testing.expect(t, glob_match("#*#", "##", false))
testing.expect(t, !glob_match("#*#", "test", false))
testing.expect(t, !glob_match("#*#", "#test", false))
}
@(test)
test_glob_empty :: proc(t: ^testing.T) {
testing.expect(t, glob_match("", "", false))
testing.expect(t, !glob_match("", "foo", false))
}
+136
View File
@@ -0,0 +1,136 @@
package findr
import "core:strings"
import "core:sync"
import "core:sys/linux"
import "core:thread"
RepoPool :: struct {
queue: [dynamic]string,
queue_mutex: sync.Mutex,
queue_sema: sync.Atomic_Sema,
results: ^[dynamic]string,
results_lock: sync.Mutex,
active: i64,
done: sync.One_Shot_Event,
threads: []^thread.Thread,
}
find_repos :: proc(
roots: []string,
results: ^[dynamic]string,
thread_count: int,
allocator := context.allocator,
) {
// TODO: This may be a code smell
context.allocator = allocator
if len(roots) == 0 do return
pool := new(RepoPool)
pool.queue = make([dynamic]string)
pool.results = results
pool.active = i64(len(roots))
pool.threads = make([]^thread.Thread, thread_count)
for root in roots {
root_clone := strings.clone(root)
append(&pool.queue, root_clone)
sync.atomic_sema_post(&pool.queue_sema)
}
for i in 0 ..< thread_count {
t := thread.create(repo_worker)
t.data = rawptr(pool)
t.init_context = context
thread.start(t)
pool.threads[i] = t
}
sync.one_shot_event_wait(&pool.done)
for _ in 0 ..< thread_count {
sync.atomic_sema_post(&pool.queue_sema)
}
for t in pool.threads {
thread.destroy(t)
}
delete(pool.threads)
for path in pool.queue {
delete(path)
}
delete(pool.queue)
free(pool)
}
repo_worker :: proc(t: ^thread.Thread) {
pool := cast(^RepoPool)t.data
for {
sync.atomic_sema_wait(&pool.queue_sema)
sync.mutex_lock(&pool.queue_mutex)
if len(pool.queue) == 0 {
sync.mutex_unlock(&pool.queue_mutex)
if sync.atomic_load_explicit(&pool.active, .Acquire) == 0 {
sync.one_shot_event_signal(&pool.done)
}
break
}
last := len(pool.queue) - 1
dir_path := pool.queue[last]
ordered_remove(&pool.queue, last)
sync.mutex_unlock(&pool.queue_mutex)
process_repo_dir(pool, dir_path)
delete(dir_path)
old := sync.atomic_sub_explicit(&pool.active, 1, .Release)
if old == 1 {
sync.one_shot_event_signal(&pool.done)
}
}
}
process_repo_dir :: proc(pool: ^RepoPool, dir_path: string) {
cpath := strings.clone_to_cstring(dir_path)
if cpath == nil do return
defer delete(cpath)
fd, open_err := linux.open(cpath, {.DIRECTORY, .CLOEXEC})
if open_err != .NONE do return
defer linux.close(fd)
if has_git_dir(fd) {
cloned := strings.clone(dir_path)
sync.mutex_lock(&pool.results_lock)
append(pool.results, cloned)
sync.mutex_unlock(&pool.results_lock)
}
buf: [32 * 1024]u8
for {
n, errno := linux.getdents(fd, buf[:])
if n <= 0 || errno != .NONE do break
offs := 0
for d in linux.dirent_iterate_buf(buf[:n], &offs) {
name := linux.dirent_name(d)
if name == "." || name == ".." do continue
if name == ".git" do continue
if d.type == .DIR {
child_path := join_path(dir_path, name)
sync.atomic_add_explicit(&pool.active, 1, .Relaxed)
sync.mutex_lock(&pool.queue_mutex)
append(&pool.queue, child_path)
sync.mutex_unlock(&pool.queue_mutex)
sync.atomic_sema_post(&pool.queue_sema)
}
}
}
}
+148
View File
@@ -0,0 +1,148 @@
package findr
import "core:fmt"
import "core:log"
import "core:os"
import "core:sort"
import "core:strings"
import "core:testing"
TestEnv :: struct {
temp_dir: string,
}
create_test_env :: proc() -> (env: TestEnv) {
tmp, err := os.mkdir_temp("", "findr-test-*", context.allocator)
if err != nil {
log.error("Failed to create temp dir:", err)
panic("Failed to create temp dir")
}
env.temp_dir = tmp
return
}
destroy_test_env :: proc(env: ^TestEnv) {
os.remove_all(env.temp_dir)
delete(env.temp_dir)
}
create_dir :: proc(env: TestEnv, path: string) {
full := join_path(env.temp_dir, path)
defer delete(full)
os.mkdir_all(full, os.Permissions_Default_Directory)
}
create_file :: proc(env: TestEnv, path: string, content: string = "") {
full := join_path(env.temp_dir, path)
defer delete(full)
dir_end := strings.last_index(full, os.Path_Separator_String)
if dir_end >= 0 {
dir_path := full[:dir_end]
os.mkdir_all(dir_path, os.Permissions_Default_Directory)
}
f, err := os.create(full)
if err != nil {
log.error("Failed to create file:", full, err)
return
}
if len(content) > 0 {
os.write_string(f, content)
}
os.close(f)
}
create_git_repo :: proc(env: TestEnv, path: string) {
sub := join_path(path, ".git")
defer delete(sub)
create_dir(env, sub)
}
assert_output :: proc(
t: ^testing.T,
env: TestEnv,
args: []string,
opts: WalkOptions,
expected: []string,
) {
results := collect_results(env, args, opts)
defer {
for r in results {delete(r)}
delete(results)
}
sorted_expected := make([dynamic]string, 0, len(expected))
for e in expected {append(&sorted_expected, e)}
defer delete(sorted_expected)
sorted_actual := make([dynamic]string, 0, len(results))
for a in results {append(&sorted_actual, a)}
defer delete(sorted_actual)
sort.quick_sort(sorted_expected[:])
sort.quick_sort(sorted_actual[:])
if len(sorted_expected) != len(sorted_actual) {
testing.fail(t)
log.error(
fmt.tprintf("Expected %d results, got %d", len(sorted_expected), len(sorted_actual)),
)
log.error("Expected:", sorted_expected[:])
log.error("Actual: ", sorted_actual[:])
return
}
for i in 0 ..< len(sorted_expected) {
if sorted_expected[i] != sorted_actual[i] {
testing.fail(t)
log.error(fmt.tprintf("Mismatch at index %d", i))
log.error("Expected:", sorted_expected[:])
log.error("Actual: ", sorted_actual[:])
return
}
}
}
assert_output_empty :: proc(t: ^testing.T, env: TestEnv, args: []string, opts: WalkOptions) {
results := collect_results(env, args, opts)
defer {
for r in results {delete(r)}
delete(results)
}
if len(results) > 0 {
testing.fail(t)
log.error(fmt.tprintf("Expected no results, got %d:", len(results)))
for r in results {
log.error(" ", r)
}
}
}
collect_results :: proc(env: TestEnv, args: []string, opts: WalkOptions) -> [dynamic]string {
results := make([dynamic]string)
full_args := make([dynamic]string, 0, len(args) + 1, context.temp_allocator)
append(&full_args, env.temp_dir)
for a in args {append(&full_args, a)}
thread_count := os.get_processor_core_count()
walk(full_args[:], &results, opts, thread_count)
for i in 0 ..< len(results) {
r := results[i]
if strings.has_prefix(r, env.temp_dir) {
stripped := r[len(env.temp_dir):]
if len(stripped) > 0 && stripped[0] == os.Path_Separator {
stripped = stripped[1:]
}
new_r := strings.clone(stripped)
delete(r)
results[i] = new_r
}
}
return results
}
+468
View File
@@ -0,0 +1,468 @@
package findr
import "core:bytes"
import "core:fmt"
import "core:os"
import "core:strings"
import "core:sync"
import "core:sync/chan"
import "core:sys/linux"
import "core:text/regex"
import "core:thread"
OUTPUT_BUF_SIZE :: 64 * 1024
WalkOptions :: struct {
pattern: string, // regex on basename; "" = match all
excludes: []string, // glob patterns to skip entirely
}
GIContext :: struct {
gi: ^Gitignore, // nil if this dir had no .gitignore
base_rel: string, // relative path from repo root to this dir
parent: ^GIContext, // parent context (nil if repo root)
}
WorkItem :: struct {
path: string, // absolute directory path
rel: string, // relative path from repo root ("" = root)
gi_ctx: ^GIContext, // gitignore chain (nil = outside any repo)
in_repo: bool, // true if inside a git repo
in_ignored: bool, // true if inside a gitignored directory
}
WalkerPool :: struct {
queue: [dynamic]WorkItem,
queue_mutex: sync.Mutex,
queue_sema: sync.Atomic_Sema,
result_chan: chan.Chan([]u8),
active: i64,
done: sync.One_Shot_Event,
threads: []^thread.Thread,
opts: WalkOptions,
pattern_re: regex.Regular_Expression,
has_pattern: bool,
exclude_gi: ^Gitignore,
all_contexts: [dynamic]^GIContext,
contexts_lock: sync.Mutex,
}
Collector_Data :: struct {
ch: chan.Chan([]u8),
results: ^[dynamic]string,
}
collect_worker :: proc(t: ^thread.Thread) {
data := cast(^Collector_Data)t.data
for {
batch := chan.recv(data.ch) or_break
defer delete(batch)
start := 0
for {
remaining: []u8
#no_bounds_check {remaining = batch[start:]}
idx := bytes.index_byte(remaining, '\n')
if idx < 0 do break
i := start + idx
if i > start {
segment: []u8
#no_bounds_check {segment = batch[start:i]}
s, _ := strings.clone(string(segment))
append(data.results, s)
}
start = i + 1
}
}
}
walk :: proc(
roots: []string,
results: ^[dynamic]string,
opts: WalkOptions,
thread_count: int,
allocator := context.allocator,
) {
// TODO: This may be a code smell
context.allocator = allocator
if len(roots) == 0 do return
ch, _ := chan.create(chan.Chan([]u8), max(2 * thread_count, 2), context.allocator)
defer chan.destroy(ch)
data := new(Collector_Data)
data.ch = ch
data.results = results
defer free(data)
collector := thread.create(collect_worker)
collector.data = rawptr(data)
collector.init_context = context
thread.start(collector)
pool := new(WalkerPool)
pool.queue = make([dynamic]WorkItem)
pool.result_chan = ch
pool.active = i64(len(roots))
pool.threads = make([]^thread.Thread, thread_count)
pool.all_contexts = make([dynamic]^GIContext)
pool.opts = opts
pool.exclude_gi = nil
pool.has_pattern = false
if len(opts.pattern) > 0 {
re, err := regex.create(opts.pattern, {regex.Flag.No_Capture})
if err == nil {
pool.pattern_re = re
pool.has_pattern = true
}
}
if len(opts.excludes) > 0 {
sb: strings.Builder
strings.builder_init(&sb)
for ex in opts.excludes {
fmt.sbprintf(&sb, "%s\n", ex)
}
content := strings.to_string(sb)
pool.exclude_gi = new(Gitignore)
pool.exclude_gi^ = parse(content)
strings.builder_destroy(&sb)
}
for root in roots {
root_clone, _ := strings.clone(root)
append(&pool.queue, WorkItem{path = root_clone})
sync.atomic_sema_post(&pool.queue_sema)
}
for i in 0 ..< thread_count {
t := thread.create(walk_worker)
t.data = rawptr(pool)
t.init_context = context
thread.start(t)
pool.threads[i] = t
}
sync.one_shot_event_wait(&pool.done)
for _ in 0 ..< thread_count {
sync.atomic_sema_post(&pool.queue_sema)
}
for t in pool.threads {
thread.destroy(t)
}
delete(pool.threads)
for item in pool.queue {
delete(item.path)
if len(item.rel) > 0 {delete(item.rel)}
}
delete(pool.queue)
for ctx in pool.all_contexts {
if ctx.gi != nil {
destroy(ctx.gi)
free(ctx.gi)
}
if len(ctx.base_rel) > 0 {
delete(ctx.base_rel)
}
free(ctx)
}
delete(pool.all_contexts)
if pool.has_pattern {
regex.destroy(pool.pattern_re)
}
if pool.exclude_gi != nil {
destroy(pool.exclude_gi)
free(pool.exclude_gi)
}
free(pool)
chan.close(ch)
thread.join(collector)
thread.destroy(collector)
}
flush_buf :: proc(ch: chan.Chan([]u8), local: ^[dynamic]u8) {
if len(local) == 0 do return
batch := local[:]
local^ = make([dynamic]u8, 0, OUTPUT_BUF_SIZE)
chan.send(ch, batch)
}
append_path :: proc(buf: ^[dynamic]u8, parent, name: string, trailing_slash: bool) {
need_sep := len(parent) > 0 && parent[len(parent) - 1] != os.Path_Separator
size := len(parent) + len(name) + 1
if need_sep do size += 1
if trailing_slash do size += 1
old_len := len(buf)
reserve(buf, old_len + size)
resize(buf, old_len + size)
pos := old_len
pos += copy(buf[pos:], parent)
if need_sep {buf[pos] = os.Path_Separator; pos += 1}
pos += copy(buf[pos:], name)
if trailing_slash {buf[pos] = os.Path_Separator; pos += 1}
buf[pos] = '\n'
}
walk_worker :: proc(t: ^thread.Thread) {
pool := cast(^WalkerPool)t.data
local_buf := make([dynamic]u8, 0, OUTPUT_BUF_SIZE)
defer {
if len(local_buf) > 0 {
flush_buf(pool.result_chan, &local_buf)
}
delete(local_buf)
}
for {
sync.atomic_sema_wait(&pool.queue_sema)
sync.mutex_lock(&pool.queue_mutex)
if len(pool.queue) == 0 {
sync.mutex_unlock(&pool.queue_mutex)
if sync.atomic_load_explicit(&pool.active, .Acquire) == 0 {
sync.one_shot_event_signal(&pool.done)
}
break
}
last := len(pool.queue) - 1
item := pool.queue[last]
ordered_remove(&pool.queue, last)
sync.mutex_unlock(&pool.queue_mutex)
process_dir(pool, item, &local_buf)
delete(item.path)
if len(item.rel) > 0 {delete(item.rel)}
if len(local_buf) >= OUTPUT_BUF_SIZE {
flush_buf(pool.result_chan, &local_buf)
}
old := sync.atomic_sub_explicit(&pool.active, 1, .Release)
if old == 1 {
sync.one_shot_event_signal(&pool.done)
}
}
}
process_dir :: proc(pool: ^WalkerPool, item: WorkItem, local_buf: ^[dynamic]u8) {
dir_path := item.path
cpath := strings.clone_to_cstring(dir_path)
if cpath == nil do return
defer delete(cpath)
fd, open_err := linux.open(cpath, {.DIRECTORY, .CLOEXEC})
if open_err != .NONE do return
defer linux.close(fd)
has_git := false
if !item.in_ignored {
has_git = has_git_dir(fd)
}
gi_ctx := item.gi_ctx
rel := item.rel
if has_git {
gi_ctx = nil
rel = ""
}
child_in_repo := has_git || item.in_repo
gi: ^Gitignore = nil
if !item.in_ignored {
gi = load_ignore_patterns(dir_path, child_in_repo)
}
if gi != nil {
new_ctx := new(GIContext)
new_ctx.gi = gi
if len(rel) > 0 {
new_ctx.base_rel, _ = strings.clone(rel)
}
new_ctx.parent = gi_ctx
sync.mutex_lock(&pool.contexts_lock)
append(&pool.all_contexts, new_ctx)
sync.mutex_unlock(&pool.contexts_lock)
gi_ctx = new_ctx
}
buf: [32 * 1024]u8
rel_buf: [4096]u8
for {
n, errno := linux.getdents(fd, buf[:])
if n <= 0 || errno != .NONE do break
offs := 0
for d in linux.dirent_iterate_buf(buf[:n], &offs) {
name := linux.dirent_name(d)
if name == "." || name == ".." do continue
if name == ".git" do continue
is_dir := d.type == .DIR
is_nondir := d.type != .DIR
if pool.exclude_gi != nil && is_ignored(pool.exclude_gi, name, is_dir) {
continue
}
entry_rel := build_rel(rel_buf[:], rel, name)
ignored := false
if item.in_ignored {
ignored = true
} else if gi_ctx != nil {
ignored = check_chain(gi_ctx, entry_rel, is_dir)
}
if is_dir {
if ignored && matches_pattern(pool, name) {
append_path(local_buf, dir_path, name, true)
}
child_rel, _ := strings.clone(entry_rel)
child_path := join_path(dir_path, name)
push_work(
pool,
WorkItem {
path = child_path,
rel = child_rel,
gi_ctx = gi_ctx,
in_repo = child_in_repo,
in_ignored = ignored,
},
)
} else if is_nondir {
if ignored && matches_pattern(pool, name) {
append_path(local_buf, dir_path, name, false)
}
}
}
}
}
check_chain :: proc(ctx: ^GIContext, entry_rel: string, is_dir: bool) -> bool {
c := ctx
for c != nil {
if c.gi != nil {
rel := relative_to(entry_rel, c.base_rel)
match := check_match(c.gi, rel, is_dir)
if match != .None {
return match == .Ignored
}
}
c = c.parent
}
return false
}
// TODO: Is this a copy of something in the core packages?
relative_to :: proc(entry_rel, base_rel: string) -> string {
if len(base_rel) == 0 do return entry_rel
prefix_len := len(base_rel)
if len(entry_rel) > prefix_len &&
entry_rel[prefix_len] == '/' &&
strings.has_prefix(entry_rel, base_rel) {
return entry_rel[prefix_len + 1:]
}
return entry_rel
}
build_rel :: proc(buf: []u8, rel, name: string) -> string {
if len(rel) == 0 do return name
pos := copy(buf, rel)
if pos < len(buf) {
buf[pos] = '/'
pos += 1
pos += copy(buf[pos:], name)
}
return string(buf[:pos])
}
matches_pattern :: proc(pool: ^WalkerPool, name: string) -> bool {
if !pool.has_pattern do return true
cap, ok := regex.match(pool.pattern_re, name)
regex.destroy(cap)
return ok
}
push_work :: proc(pool: ^WalkerPool, item: WorkItem) {
sync.atomic_add_explicit(&pool.active, 1, .Relaxed)
sync.mutex_lock(&pool.queue_mutex)
append(&pool.queue, item)
sync.mutex_unlock(&pool.queue_mutex)
sync.atomic_sema_post(&pool.queue_sema)
}
has_git_dir :: proc(fd: linux.Fd) -> bool {
git_fd, err := linux.openat(fd, ".git", {.DIRECTORY, .CLOEXEC})
if err == .NONE {
linux.close(git_fd)
return true
}
return false
}
load_ignore_patterns :: proc(dir_path: string, in_repo: bool) -> ^Gitignore {
has_patterns := false
sb: strings.Builder
strings.builder_init(&sb)
defer strings.builder_destroy(&sb)
if in_repo {
gi_path := join_path(dir_path, ".gitignore")
data, err := os.read_entire_file_from_path(gi_path, context.allocator)
delete(gi_path)
if err == .NONE {
fmt.sbprintf(&sb, "%s", string(data))
delete(data)
has_patterns = true
}
}
ig_path := join_path(dir_path, ".ignore")
idata, ierr := os.read_entire_file_from_path(ig_path, context.allocator)
delete(ig_path)
if ierr == .NONE {
fmt.sbprintf(&sb, "%s", string(idata))
delete(idata)
has_patterns = true
}
if !has_patterns do return nil
content := strings.to_string(sb)
gi := new(Gitignore)
gi^ = parse(content)
return gi
}
// TODO: Is this a copy of core package behavior?
join_path :: proc(parent, child: string) -> string {
need_sep := len(parent) == 0 || parent[len(parent) - 1] != os.Path_Separator
total := len(parent) + len(child)
if need_sep do total += 1
buf := make([]u8, total, context.allocator)
pos := copy(buf, parent)
if need_sep {
buf[pos] = os.Path_Separator
pos += 1
}
copy(buf[pos:], child)
return string(buf)
}
+139
View File
@@ -0,0 +1,139 @@
package main
import "base:runtime"
import "core:reflect"
import "core:strings"
parse_flags :: proc(model: ^$T, args: []string) -> (overflow: []string) {
field_count := reflect.struct_field_count(T)
long_map := make(map[string]reflect.Struct_Field, field_count, context.temp_allocator)
short_map := make(map[string]reflect.Struct_Field, field_count, context.temp_allocator)
for i in 0 ..< field_count {
field := reflect.struct_field_at(T, i)
name, _ := strings.replace(field.name, "_", "-", -1, context.temp_allocator)
args_tag := reflect.struct_tag_get(field.tag, "args")
if n, ok := get_subtag(args_tag, "name"); ok {
name = n
}
long_map[name] = field
if s, ok := get_subtag(args_tag, "short"); ok {
short_map[s] = field
}
}
overflow_dyn := make([dynamic]string, 0, len(args), context.temp_allocator)
i := 0
for i < len(args) {
arg := args[i]
if strings.starts_with(arg, "--") {
key := arg[2:]
value := ""
has_value := false
if eq_idx := strings.index(key, "="); eq_idx >= 0 {
value = key[eq_idx + 1:]
key = key[:eq_idx]
has_value = true
}
if field, ok := long_map[key]; ok {
if is_bool_type(field) {
set_field(model, field, "")
i += 1
} else if has_value {
set_field(model, field, value)
i += 1
} else if i + 1 < len(args) && !strings.starts_with(args[i + 1], "-") {
set_field(model, field, args[i + 1])
i += 2
} else {
i += 1
}
} else {
i += 1
}
} else if strings.starts_with(arg, "-") && len(arg) == 2 {
short := arg[1:2]
if field, ok := short_map[short]; ok {
if is_bool_type(field) {
set_field(model, field, "")
i += 1
} else if i + 1 < len(args) && !strings.starts_with(args[i + 1], "-") {
set_field(model, field, args[i + 1])
i += 2
} else {
i += 1
}
} else {
i += 1
}
} else {
append(&overflow_dyn, arg)
i += 1
}
}
return overflow_dyn[:]
}
get_subtag :: proc(tag: string, id: string) -> (value: string, ok: bool) {
parts := strings.split(tag, ",", context.temp_allocator)
for part in parts {
trimmed := strings.trim_space(part)
if strings.has_prefix(trimmed, id) && len(trimmed) > len(id) && trimmed[len(id)] == '=' {
return trimmed[len(id) + 1:], true
}
if trimmed == id {
return "", true
}
}
return "", false
}
is_bool_type :: proc(field: reflect.Struct_Field) -> bool {
base_ti := runtime.type_info_base(field.type)
_, is_bool := base_ti.variant.(runtime.Type_Info_Boolean)
return is_bool
}
set_field :: proc(model: rawptr, field: reflect.Struct_Field, value: string) -> bool {
ptr := rawptr(uintptr(model) + field.offset)
base_ti := runtime.type_info_base(field.type)
if _, is_bool := base_ti.variant.(runtime.Type_Info_Boolean); is_bool {
(cast(^bool)ptr)^ = true
return true
}
if _, is_string := base_ti.variant.(runtime.Type_Info_String); is_string {
(cast(^string)ptr)^ = value
return true
}
if enum_ti, is_enum := base_ti.variant.(runtime.Type_Info_Enum); is_enum {
for name, i in enum_ti.names {
if strings.equal_fold(value, name) {
v := enum_ti.values[i]
switch base_ti.size {
case 1:
(cast(^u8)ptr)^ = cast(u8)v
case 2:
(cast(^u16)ptr)^ = cast(u16)v
case 4:
(cast(^u32)ptr)^ = cast(u32)v
case 8:
(cast(^u64)ptr)^ = cast(u64)v
}
return true
}
}
}
return false
}
+27 -7
View File
@@ -36,11 +36,11 @@
inputs',
...
}:
let
mysqlite = pkgs.sqlite.overrideAttrs (old: {
configureFlags = (old.configureFlags or [ ]) ++ [ "--enable-deserialize" ];
});
in
let
mysqlite = pkgs.sqlite.overrideAttrs (old: {
configureFlags = (old.configureFlags or [ ]) ++ [ "--enable-deserialize" ];
});
in
{
_module.args.pkgs = import nixpkgs {
inherit system;
@@ -66,7 +66,7 @@
packages.default = pkgs.stdenv.mkDerivation rec {
pname = "envr";
version = "0.2.0";
version = nixpkgs.lib.trim (builtins.readFile ./version.txt);
src = ./.;
nativeBuildInputs = [
@@ -75,13 +75,27 @@
];
buildInputs = [
pkgs.git
pkgs.libsodium
mysqlite
];
doCheck = true;
checkPhase = ''
runHook preCheck
odin test . -all-packages
runHook postCheck
'';
buildPhase = ''
runHook preBuild
echo '${version}' > version.txt
# Generate man pages before main build (binary gets clobbered)
odin build . -define:GENDOCS=true -out:gendocs
./gendocs --man
odin build . -o:speed -out:${pname}
runHook postBuild
'';
@@ -89,13 +103,15 @@
installPhase = ''
runHook preInstall
install -Dm755 ${pname} $out/bin/${pname}
mkdir -p $out/share/man/man1
cp docs/man/*.1 $out/share/man/man1/
runHook postInstall
'';
};
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
fd
nushell
libsodium
@@ -106,6 +122,10 @@
# Build tools
zip
# Helper tools
delta
hyperfine
# IDE
unstable.helix
typescript-language-server
+289
View File
@@ -0,0 +1,289 @@
package main
import "core:fmt"
import "core:os"
import "core:strings"
import "core:time"
when #config(GENDOCS, false) {
main :: proc() {
args := os.args
if len(args) < 2 {
fmt.eprintln("Usage: gendocs --man | --md")
os.exit(1)
}
switch args[1] {
case "--man":
generate_man_pages()
case "--md":
generate_markdown()
case:
fmt.eprintf("Unknown format: %s\n", args[1])
os.exit(1)
}
}
}
generate_man_pages :: proc() {
os.mkdir_all("docs/man")
now := time.now()
month_year := fmt.tprintf("%s %d", time.month(now), time.year(now))
generate_man_main(month_year)
for &c in COMMANDS {
generate_man_command(&c, month_year)
}
fmt.println("Generated man pages in docs/man/")
}
generate_man_main :: proc(month_year: string) {
sb: strings.Builder
strings.builder_init(&sb, context.temp_allocator)
defer strings.builder_destroy(&sb)
fmt.sbprintf(&sb, ".TH ENVR 1 \"%s\" \"envr\" \"envr Manual\"\n", month_year)
fmt.sbprintf(&sb, ".SH NAME\nenvr \\- keep your .env files synced to an encrypted database\n")
fmt.sbprintf(&sb, ".SH SYNOPSIS\n\\fBenvr\\fR [\\fIcommand\\fR] [\\fIflags\\fR]\n")
fmt.sbprintf(
&sb,
".SH DESCRIPTION\nenvr keeps your .env synced to a local, encrypted database.\n",
)
fmt.sbprintf(
&sb,
"Is a safe and easy way to gather all your .env files in one place where they can\n",
)
fmt.sbprintf(&sb, "easily be backed by another tool such as restic or git.\n")
fmt.sbprintf(&sb, ".PP\nAll your data is stored in ~/.envr/data.envr\n")
fmt.sbprintf(&sb, ".SH COMMANDS\n")
for c in COMMANDS {
fmt.sbprintf(&sb, ".TP\n\\fB%s\\fR\n%s\n", c.name, c.short)
for a in c.aliases {
fmt.sbprintf(&sb, ".TP\n\\fB%s\\fR (alias for %s)\n%s\n", a, c.name, c.short)
}
}
fmt.sbprintf(&sb, ".SH GLOBAL OPTIONS\n")
for ft in Flag_Type {
if ft not_in GLOBAL_FLAGS do continue
write_man_flag(&sb, ft)
}
fmt.sbprintf(&sb, ".SH SEE ALSO\n")
for c, i in COMMANDS {
if i > 0 do fmt.sbprintf(&sb, ",\n")
fmt.sbprintf(&sb, ".BR envr-%s (1)", c.name)
}
fmt.sbprintf(&sb, "\n")
path := "docs/man/envr.1"
err := os.write_entire_file(path, transmute([]u8)strings.to_string(sb))
if err != nil {
fmt.eprintf("Error writing %s: %v\n", path, err)
}
}
generate_man_command :: proc(c: ^CommandInfo, month_year: string) {
sb: strings.Builder
strings.builder_init(&sb, context.temp_allocator)
defer strings.builder_destroy(&sb)
upper_name := strings.to_upper(fmt.tprintf("envr-%s", c.name))
fmt.sbprintf(&sb, ".TH %s 1 \"%s\" \"envr\" \"envr Manual\"\n", upper_name, month_year)
fmt.sbprintf(&sb, ".SH NAME\nenvr-%s \\- %s\n", c.name, c.short)
fmt.sbprintf(&sb, ".SH SYNOPSIS\n\\fB%s\\fR", c.usage)
has_positionals := false
for arg in c.args {
has_positionals = true
if arg.optional {
fmt.sbprintf(&sb, " [\\fI%s\\fR]", arg.name)
} else {
fmt.sbprintf(&sb, " \\fI%s\\fR", arg.name)
}
}
fmt.sbprintf(&sb, "\n")
if len(c.long) > 0 {
fmt.sbprintf(&sb, ".SH DESCRIPTION\n%s\n", c.long)
}
if has_positionals {
fmt.sbprintf(&sb, ".SH ARGUMENTS\n")
for arg in c.args {
fmt.sbprintf(&sb, ".TP\n\\fB%s\\fR\n%s\n", arg.name, arg.desc)
}
}
fmt.sbprintf(&sb, ".SH OPTIONS\n")
for ft in Flag_Type {
if ft not_in c.flags do continue
write_man_flag(&sb, ft)
}
path := fmt.tprintf("docs/man/envr-%s.1", c.name)
err := os.write_entire_file(path, transmute([]u8)strings.to_string(sb))
if err != nil {
fmt.eprintf("Error writing %s: %v\n", path, err)
}
}
write_man_flag :: proc(sb: ^strings.Builder, ft: Flag_Type) {
f := flag_field(ft)
fmt.sbprintf(sb, ".TP\n")
if len(f.short_name) > 0 {
fmt.sbprintf(sb, "\\fB-%s\\fR, \\fB--%s\\fR", f.short_name, f.long_name)
} else {
fmt.sbprintf(sb, "\\fB--%s\\fR", f.long_name)
}
#partial switch f.kind {
case .String:
fmt.sbprintf(sb, " \\fIvalue\\fR")
case .Enum:
values, _ := strings.replace(f.enum_values, "'", "", -1)
fmt.sbprintf(sb, " \\fI%s\\fR", values)
}
fmt.sbprintf(sb, "\n%s\n", f.usage)
if len(f.default_val) > 0 {
#partial switch f.kind {
case .String:
fmt.sbprintf(sb, "(default \"%s\")\n", f.default_val)
case .Enum:
fmt.sbprintf(sb, "(default '%s')\n", f.default_val)
}
}
}
generate_markdown :: proc() {
os.mkdir_all("docs/cli")
generate_md_main()
for &c in COMMANDS {
generate_md_command(&c)
}
fmt.println("Generated markdown in docs/cli/")
}
generate_md_main :: proc() {
sb: strings.Builder
strings.builder_init(&sb, context.temp_allocator)
defer strings.builder_destroy(&sb)
fmt.sbprintf(&sb, "## envr\n\n")
fmt.sbprintf(&sb, "Manage your .env files.\n\n")
fmt.sbprintf(&sb, "### Synopsis\n\n")
fmt.sbprint(&sb, ENVR_DESCRIPTION)
fmt.sbprintf(&sb, "\n\n### Options\n\n")
write_md_options(&sb, {.Help})
fmt.sbprintf(&sb, "\n### SEE ALSO\n\n")
for c in COMMANDS {
fmt.sbprintf(&sb, "* [envr %s](envr_%s.md)\t - %s\n", c.name, c.name, c.short)
}
fmt.sbprintf(&sb, "\n")
write_file("docs/cli/envr.md", strings.to_string(sb))
}
generate_md_command :: proc(c: ^CommandInfo) {
sb: strings.Builder
strings.builder_init(&sb, context.temp_allocator)
defer strings.builder_destroy(&sb)
fmt.sbprintf(&sb, "## envr %s\n\n", c.name)
fmt.sbprintf(&sb, "%s\n\n", c.short)
if len(c.long) > 0 {
fmt.sbprintf(&sb, "### Synopsis\n\n")
fmt.sbprint(&sb, c.long)
fmt.sbprintf(&sb, "\n\n")
}
fmt.sbprintf(&sb, "```\n%s [flags]\n```\n\n", c.usage)
if len(c.args) > 0 {
fmt.sbprintf(&sb, "**Arguments:**\n\n")
for arg in c.args {
optional := arg.optional ? " (optional)" : ""
fmt.sbprintf(&sb, "* `%s` — %s%s\n", arg.name, arg.desc, optional)
}
fmt.sbprintf(&sb, "\n")
}
fmt.sbprintf(&sb, "### Options\n\n")
write_md_options(&sb, c.flags)
for a in c.aliases {
fmt.sbprintf(&sb, "\n*Alias: `%s`*\n", a)
}
fmt.sbprintf(&sb, "\n### SEE ALSO\n\n")
fmt.sbprintf(&sb, "* [envr](envr.md)\t - Manage your .env files.\n")
path := fmt.tprintf("docs/cli/envr_%s.md", c.name)
write_file(path, strings.to_string(sb))
}
write_md_options :: proc(sb: ^strings.Builder, flags: bit_set[Flag_Type]) {
displays := make([dynamic]string, 0, 5, context.temp_allocator)
defer delete(displays)
descs := make([dynamic]string, 0, 5, context.temp_allocator)
defer delete(descs)
max_width := 0
for ft in Flag_Type {
if ft not_in flags do continue
f := flag_field(ft)
display: string
if len(f.short_name) > 0 {
display = fmt.tprintf("-%s, --%s", f.short_name, f.long_name)
} else {
display = fmt.tprintf(" --%s", f.long_name)
}
desc := f.usage
if len(f.default_val) > 0 {
#partial switch f.kind {
case .String:
desc = fmt.tprintf(`%s (default "%s")`, f.usage, f.default_val)
case .Enum:
desc = fmt.tprintf("%s (default '%s')", f.usage, f.default_val)
}
}
append(&displays, display)
append(&descs, desc)
if len(display) > max_width {
max_width = len(display)
}
}
fmt.sbprintf(sb, "```\n")
pad_buf: [64]u8
for i in 0..<len(displays) {
padding := max_width - len(displays[i]) + 3
for j in 0..<padding {
pad_buf[j] = ' '
}
fmt.sbprintf(sb, " %s%s%s\n", displays[i], string(pad_buf[:padding]), descs[i])
}
fmt.sbprintf(sb, "```\n")
}
write_file :: proc(path: string, content: string) {
err := os.write_entire_file(path, transmute([]u8)content)
if err != nil {
fmt.eprintf("Error writing %s: %v\n", path, err)
}
}
+100
View File
@@ -0,0 +1,100 @@
_envr() {
local cur prev cmd
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
cmd="${COMP_WORDS[1]}"
if [[ $COMP_CWORD -eq 1 ]]; then
COMPREPLY=( $(compgen -W "init scan sync backup add restore list remove check version edit-config completion" -- "$cur") )
return
fi
case "$prev" in
--config-file|-c)
COMPREPLY=( $(compgen -f -- "$cur") )
return
;;
--output|-o)
COMPREPLY=( $(compgen -W "auto table json" -- "$cur") )
return
;;
--color)
COMPREPLY=( $(compgen -W "auto always never" -- "$cur") )
return
;;
esac
case "$cur" in -*)
case "$cmd" in
init)
COMPREPLY=( $(compgen -W "--help -h --config-file -c --color --force -f" -- "$cur") )
return
;;
scan)
COMPREPLY=( $(compgen -W "--help -h --config-file -c --color" -- "$cur") )
return
;;
sync)
COMPREPLY=( $(compgen -W "--help -h --config-file -c --output -o --color" -- "$cur") )
return
;;
backup|add)
COMPREPLY=( $(compgen -W "--help -h --config-file -c --color" -- "$cur") )
return
;;
restore)
COMPREPLY=( $(compgen -W "--help -h --config-file -c --color" -- "$cur") )
return
;;
list)
COMPREPLY=( $(compgen -W "--help -h --config-file -c --output -o --color" -- "$cur") )
return
;;
remove)
COMPREPLY=( $(compgen -W "--help -h --config-file -c --color" -- "$cur") )
return
;;
check)
COMPREPLY=( $(compgen -W "--help -h --config-file -c --color" -- "$cur") )
return
;;
version)
COMPREPLY=( $(compgen -W "--help -h" -- "$cur") )
return
;;
edit-config)
COMPREPLY=( $(compgen -W "--help -h --config-file -c --color" -- "$cur") )
return
;;
completion)
COMPREPLY=( $(compgen -W "--help -h" -- "$cur") )
return
;;
esac
;;
esac
case "$cmd" in
backup|add)
COMPREPLY=( $(compgen -W "$(envr scan --output json 2>/dev/null)" -- "$cur") )
return
;;
restore)
COMPREPLY=( $(compgen -W "$(envr list --output json 2>/dev/null)" -- "$cur") )
return
;;
remove)
COMPREPLY=( $(compgen -W "$(envr list --output json 2>/dev/null)" -- "$cur") )
return
;;
completion)
COMPREPLY=( $(compgen -W "nushell bash" -- "$cur") )
return
;;
esac
}
complete -F _envr envr
+99 -36
View File
@@ -1,47 +1,110 @@
package main
import "core:bufio"
import "base:runtime"
import "core:fmt"
import "core:mem"
import "core:os"
import "core:prof/spall"
import "core:sync"
main :: proc() {
defer free_all(context.temp_allocator)
SPALL :: #config(SPALL, false)
when SPALL {
spall_ctx: spall.Context
@(thread_local)
spall_buffer: spall.Buffer
}
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 {
return
when !#config(GENDOCS, false) {
main :: proc() {
when SPALL {
ctx, spall_ok := spall.context_create_with_scale("envr.spall", false, 1.0)
if !spall_ok {
fmt.eprintln("Failed to create spall trace file")
os.exit(1)
}
spall_ctx = ctx
defer spall.context_destroy(&spall_ctx)
spall_backing := make([]u8, spall.BUFFER_DEFAULT_SIZE)
defer delete(spall_backing)
spall_buffer = spall.buffer_create(spall_backing, u32(sync.current_thread_id()))
defer spall.buffer_destroy(&spall_ctx, &spall_buffer)
}
when ODIN_DEBUG {
heap_track: mem.Tracking_Allocator
mem.tracking_allocator_init(&heap_track, context.allocator)
defer mem.tracking_allocator_destroy(&heap_track)
defer if len(heap_track.allocation_map) > 0 {
for _, leak in heap_track.allocation_map {
fmt.eprintf("LEAK: %v leaked %m\n", leak.location, leak.size)
}
}
context.allocator = mem.tracking_allocator(&heap_track)
temp_track: mem.Tracking_Allocator
mem.tracking_allocator_init(&temp_track, context.temp_allocator)
defer mem.tracking_allocator_destroy(&temp_track)
context.temp_allocator = mem.tracking_allocator(&temp_track)
}
defer free_all(context.temp_allocator)
cmd, ok := parse_args(os.args, os.to_writer(os.stdout), os.to_writer(os.stderr))
defer delete_command(&cmd) // delete flushes automatically
if !ok {
return
}
switch cmd.name {
case "init":
cmd_init(&cmd)
case "version":
cmd_version(&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 "completion":
cmd_completion(&cmd)
case "uninstall":
cmd_uninstall(&cmd)
case:
fmt.wprintf(cmd.err, "Unknown command: %s\n", cmd.name)
write_usage(cmd.out)
os.exit(1)
}
}
} // !GENDOCS
when SPALL {
@(instrumentation_enter)
spall_enter :: proc "contextless" (
proc_address, call_site_return_address: rawptr,
loc: runtime.Source_Code_Location,
) {
spall._buffer_begin(&spall_ctx, &spall_buffer, "", "", loc)
}
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.wprintf(cmd.err, "Unknown command: %s\n", cmd.name)
write_usage(cmd.out)
os.exit(1)
@(instrumentation_exit)
spall_exit :: proc "contextless" (
proc_address, call_site_return_address: rawptr,
loc: runtime.Source_Code_Location,
) {
spall._buffer_end(&spall_ctx, &spall_buffer)
}
}
-71
View File
@@ -1,71 +0,0 @@
# envr command extern definitions for Nushell
# A tool for managing environment files and backups
export def tracked-paths [] {
(
^envr list
| from json
| each {
[$in.directory $in.path] | path join
}
)
}
export def untracked-paths [] {
(
^envr scan
| from json
)
}
export extern envr [
...args: any
--help(-h) # Show help information
]
export extern "envr backup" [
--help(-h) # Show help for backup command
path: path@untracked-paths # Path to .env file to backup
]
export extern "envr check" [
--help(-h) # Show help for check command
]
export extern "envr edit-config" [
--help(-h) # Show help for edit-config command
]
export extern "envr help" [
command?: string # Show help for specific command
]
export extern "envr init" [
--help(-h) # Show help for init command
]
export extern "envr list" [
--help(-h) # Show help for list command
]
export extern "envr remove" [
--help(-h) # Show help for remove command
path: path@tracked-paths
]
export extern "envr restore" [
--help(-h) # Show help for restore command
path: path@tracked-paths
]
export extern "envr scan" [
--help(-h) # Show help for scan command
]
export extern "envr sync" [
--help(-h) # Show help for sync command
]
export extern "envr nushell-completion" [
--help(-h) # Show help for nushell-completion command
]
+97 -32
View File
@@ -1,7 +1,9 @@
package main
import "core:fmt"
import "core:strings"
import "core:sys/posix"
import "core:terminal/ansi"
MultiSelect_Result :: enum {
Confirm,
@@ -14,6 +16,8 @@ Key :: enum {
Space,
Enter,
Escape,
Backspace,
Char,
Unknown,
}
@@ -36,48 +40,84 @@ multi_select :: proc(
return
}
selected = make([dynamic]bool, 0, len(options))
selected = make([dynamic]bool, len(options))
filter: [dynamic]u8
defer delete(filter)
filtered: [dynamic]int
defer delete(filtered)
rebuild_filtered(options, filter[:], &filtered)
cursor: int = 0
scroll_offset: int = 0
fmt.printf("\x1b[?25l")
visible := render_options(prompt, options, selected[:], cursor, scroll_offset)
fmt.printf(ansi.CSI + ansi.DECTCEM_HIDE)
visible := render_options(prompt, options, selected[:], filtered[:], string(filter[:]), cursor, scroll_offset)
raw, ok := enable_raw_mode(posix.STDIN_FILENO)
if !ok {
fmt.printf("\x1b[?25h")
fmt.printf(ansi.CSI + ansi.DECTCEM_SHOW)
return
}
defer disable_raw_mode(&raw)
for {
key := read_key()
key, ch := read_key()
switch key {
case .Char:
append(&filter, u8(ch))
rebuild_filtered(options, filter[:], &filtered)
cursor = clamp(cursor, 0, max(0, len(filtered) - 1))
case .Backspace:
if len(filter) > 0 {
pop(&filter)
rebuild_filtered(options, filter[:], &filtered)
cursor = clamp(cursor, 0, max(0, len(filtered) - 1))
}
case .Up:
if cursor > 0 {
cursor -= 1
}
case .Down:
if cursor < len(options) - 1 {
if cursor < len(filtered) - 1 {
cursor += 1
}
case .Space:
selected[cursor] = !selected[cursor]
if len(filtered) > 0 {
selected[filtered[cursor]] = !selected[filtered[cursor]]
}
case .Enter:
fmt.printf("\x1b[%dA\x1b[J\x1b[?25h", visible + 1)
fmt.printf(
ansi.CSI + "%d" + ansi.CUU + ansi.CSI + ansi.ED + ansi.CSI + ansi.DECTCEM_SHOW,
visible + 1,
)
result = .Confirm
return
case .Escape:
fmt.printf("\x1b[%dA\x1b[J\x1b[?25h", visible + 1)
fmt.printf(
ansi.CSI + "%d" + ansi.CUU + ansi.CSI + ansi.ED + ansi.CSI + ansi.DECTCEM_SHOW,
visible + 1,
)
result = .Cancel
return
case .Unknown:
}
scroll_offset = max(0, min(cursor - MAX_VISIBLE / 2, len(options) - MAX_VISIBLE))
fmt.printf("\x1b[%dA\x1b[0J", visible + 1)
visible = render_options(prompt, options, selected[:], cursor, scroll_offset)
scroll_offset = max(0, min(cursor - MAX_VISIBLE / 2, len(filtered) - MAX_VISIBLE))
fmt.printf(ansi.CSI + "%d" + ansi.CUU + ansi.CSI + ansi.RESET + ansi.ED, visible + 1)
visible = render_options(prompt, options, selected[:], filtered[:], string(filter[:]), cursor, scroll_offset)
}
}
rebuild_filtered :: proc(options: []string, filter: []u8, filtered: ^[dynamic]int) {
clear(filtered)
filter_str := string(filter)
filter_lower := strings.to_lower(filter_str, context.temp_allocator)
for opt, i in options {
opt_lower := strings.to_lower(opt, context.temp_allocator)
if strings.contains(opt_lower, filter_lower) {
append(filtered, i)
}
}
}
@@ -85,29 +125,49 @@ render_options :: proc(
prompt: string,
options: []string,
selected: []bool,
filtered: []int,
filter_text: string,
cursor: int,
scroll_offset: int,
) -> int {
fmt.printf("\x1b[1;36m%s\x1b[0m (↑/↓ move, space select, enter confirm)\r\n", prompt)
fmt.printf(
"%s (type to filter, ↑/↓ move, space select, enter confirm)\r\n",
colorize(.Option_Label, prompt),
)
fmt.printf("filter: %s\r\n", filter_text)
line_count := 1
if len(filtered) == 0 {
fmt.printf(" No matches\r\n")
return line_count + 1
}
end := scroll_offset + MAX_VISIBLE
if end > len(options) {
end = len(options)
if end > len(filtered) {
end = len(filtered)
}
for i in scroll_offset ..< end {
original := filtered[i]
checkbox := " "
if selected[i] {
if selected[original] {
checkbox = "x"
}
if i == cursor {
fmt.printf("\x1b[1;32m> \x1b[0m[\x1b[32m%s\x1b[0m] %s\r\n", checkbox, options[i])
fmt.printf(
"%s [%s] %s\r\n",
colorize(.Caret, ">"),
colorize(.Sucess, checkbox),
options[original],
)
} else {
fmt.printf(" [\x1b[2m%s\x1b[0m] %s\r\n", checkbox, options[i])
fmt.printf(" [%s] %s\r\n", colorize(.Sucess, checkbox), options[original])
}
}
return end - scroll_offset
return line_count + (end - scroll_offset)
}
enable_raw_mode :: proc(fd: posix.FD) -> (Raw_State, bool) {
@@ -137,21 +197,23 @@ disable_raw_mode :: proc(state: ^Raw_State) {
posix.tcsetattr(state.fd, .TCSAFLUSH, &state.original)
}
read_key :: proc() -> Key {
read_key :: proc() -> (key: Key, ch: rune) {
buf: [3]u8
n := posix.read(posix.STDIN_FILENO, &buf[0], 1)
if n <= 0 {
return .Unknown
return .Unknown, 0
}
switch buf[0] {
case ' ':
return .Space
return .Space, 0
case '\n', '\r':
return .Enter
return .Enter, 0
case 0x03:
return .Escape
return .Escape, 0
case 0x08, 0x7F:
return .Backspace, 0
case 0x1b:
tv: posix.timeval
tv.tv_sec = 0
@@ -163,12 +225,12 @@ read_key :: proc() -> Key {
ready := posix.select(1, &set, nil, nil, &tv)
if ready <= 0 {
return .Escape
return .Escape, 0
}
n2 := posix.read(posix.STDIN_FILENO, &buf[1], 1)
if n2 <= 0 || buf[1] != '[' {
return .Escape
return .Escape, 0
}
posix.FD_ZERO(&set)
@@ -178,24 +240,27 @@ read_key :: proc() -> Key {
ready = posix.select(1, &set, nil, nil, &tv)
if ready <= 0 {
return .Escape
return .Escape, 0
}
n3 := posix.read(posix.STDIN_FILENO, &buf[2], 1)
if n3 <= 0 {
return .Escape
return .Escape, 0
}
switch buf[2] {
case 'A':
return .Up
return .Up, 0
case 'B':
return .Down
return .Down, 0
case:
return .Escape
return .Escape, 0
}
case:
return .Unknown
if buf[0] >= 0x20 && buf[0] <= 0x7E {
return .Char, rune(buf[0])
}
return .Unknown, 0
}
}
+7 -122
View File
@@ -1,140 +1,25 @@
package main
import "core:fmt"
import "core:os"
import "core:strings"
import "core:sync"
import "core:terminal"
fd_counter: sync.Atomic_Mutex
fd_seq: int
import "findr"
// Caller is responsible for freeing paths
scan_path :: proc(search_path: string, cfg: Config) -> (paths: [dynamic]string, ok: bool) {
if terminal.is_terminal(os.stdout) {
fmt.printf("Searching for all files in \"%s\"...\n", search_path)
opts := findr.WalkOptions {
pattern = cfg.scan_config.matcher,
excludes = cfg.scan_config.exclude[:],
}
all_files, all_ok := run_fd(build_fd_args(search_path, cfg, true))
if !all_ok {
return
}
if terminal.is_terminal(os.stdout) {
fmt.printf("Search for unignored fies in \"%s\"...\n", search_path)
}
unignored_files, unignored_ok := run_fd(build_fd_args(search_path, cfg, false))
if !unignored_ok {
return
}
unignored_set := make(map[string]bool, len(unignored_files), context.temp_allocator)
for file in unignored_files {
unignored_set[file] = true
}
for file in all_files {
if !(file in unignored_set) {
append(&paths, file)
}
}
findr.walk({search_path}, &paths, opts, os.get_processor_core_count())
ok = true
return
}
@(private = "file")
build_fd_args :: proc(search_path: string, cfg: Config, include_ignored: bool) -> []string {
args_len := 3 + 2 * len(cfg.ScanConfig.Exclude) + 2
args := make([dynamic]string, 0, args_len, context.temp_allocator)
append(&args, "fd")
append(&args, "-a")
append(&args, cfg.ScanConfig.Matcher)
for exclude in cfg.ScanConfig.Exclude {
append(&args, "-E")
append(&args, exclude)
}
if include_ignored {
append(&args, "-HI")
} else {
append(&args, "-H")
}
append(&args, search_path)
return args[:]
}
run_fd :: proc(args: []string) -> (lines: []string, ok: bool) {
tmp_path := next_fd_tmp_path()
tmp_file, tmp_err := os.open(tmp_path, os.O_CREATE | os.O_WRONLY | os.O_TRUNC)
if tmp_err != nil {
// TODO: Log a message here
return
}
desc := os.Process_Desc {
command = args,
stdout = tmp_file,
stderr = nil,
}
p, start_err := os.process_start(desc)
os.close(tmp_file)
if start_err != nil {
os.remove(tmp_path)
return
}
state, wait_err := os.process_wait(p)
if wait_err != nil || state.exit_code != 0 {
os.remove(tmp_path)
return
}
data, read_err := os.read_entire_file_from_path(tmp_path, context.temp_allocator)
os.remove(tmp_path)
if read_err != nil {
return
}
output := string(data)
output = strings.trim_space(output)
if len(output) == 0 {
ok = true
return
}
raw_lines := strings.split(output, "\n", context.temp_allocator)
result := make([dynamic]string, 0, len(raw_lines), context.temp_allocator)
for line in raw_lines {
trimmed := strings.trim_space(line)
if len(trimmed) > 0 {
append(&result, trimmed)
}
}
return result[:], true
}
@(private = "file")
next_fd_tmp_path :: proc() -> string {
sync.atomic_mutex_lock(&fd_counter)
n := fd_seq
fd_seq += 1
sync.atomic_mutex_unlock(&fd_counter)
return fmt.tprintf("/tmp/envr-fd-%d-%d", os.get_pid(), n)
}
cant_scan :: proc(feats: AvailableFeatures) -> bool {
return Feature.Fd not_in feats
}
// The returned values live on the temp_allocator
find_unbacked :: proc(local_files: []string, db_files: []EnvFile) -> []string {
// Lives until the end of the function
backed_set := make(map[string]bool, len(db_files), context.temp_allocator)
for file in db_files {
backed_set[file.Path] = true
backed_set[file.path] = true
}
unbacked := make([dynamic]string, 0, len(db_files) / 2, context.temp_allocator)
+26 -34
View File
@@ -1,18 +1,14 @@
#+test
package main
import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:strings"
import "core:testing"
@(test)
test_scan_path_finds_gitignored_env_files :: proc(t: ^testing.T) {
feats := check_features()
testing.expect(t, cant_scan(feats) == false)
base := fmt.tprintf("/tmp/envr-scan-test-%d", os.get_pid())
os.mkdir_all(base)
base := test_temp_dir(t, "envr-scan-test-*")
defer os.remove_all(base)
git_init := os.Process_Desc {
@@ -22,27 +18,35 @@ test_scan_path_finds_gitignored_env_files :: proc(t: ^testing.T) {
stderr = os.stderr,
}
p, err := os.process_start(git_init)
if err != nil {
return
}
_, wait_err := os.process_wait(p)
if wait_err != nil {
return
}
testing.expectf(t, err == nil, "Failed to run git: %v", err)
if err != nil do return
state, wait_err := os.process_wait(p)
testing.expectf(t, wait_err == nil, "Failed to wait: %v", wait_err)
if wait_err != nil do return
testing.expect(t, state.success, "command should succeed")
gitignore_path := fmt.tprintf("%s/.gitignore", base)
_ = os.write_entire_file(gitignore_path, ".env*\n")
err = os.write_entire_file(gitignore_path, ".env*\n")
testing.expectf(t, err == nil, "Failed: %v", err)
_ = os.write_entire_file(fmt.tprintf("%s/.env", base), "SECRET=1")
_ = os.write_entire_file(fmt.tprintf("%s/.env.testing", base), "TEST=1")
_ = os.write_entire_file(fmt.tprintf("%s/config.yaml", base), "key: value")
err = os.write_entire_file(fmt.tprintf("%s/.env", base), "SECRET=1")
testing.expectf(t, err == nil, "Failed: %v", err)
err = os.write_entire_file(fmt.tprintf("%s/.env.testing", base), "TEST=1")
testing.expectf(t, err == nil, "Failed: %v", err)
err = os.write_entire_file(fmt.tprintf("%s/config.yaml", base), "key: value")
testing.expectf(t, err == nil, "Failed: %v", err)
cfg := Config {
ScanConfig = ScanConfig{Matcher = "\\.env"},
scan_config = ScanConfig{matcher = "\\.env"},
}
results, ok := scan_path(base, cfg)
defer delete(results)
defer {
for path in results {
delete(path)
}
delete(results)
}
testing.expect(t, ok, "scan_path should succeed")
found_env := false
@@ -69,28 +73,16 @@ test_scan_path_finds_gitignored_env_files :: proc(t: ^testing.T) {
@(test)
test_scan_path_empty_dir :: proc(t: ^testing.T) {
feats := check_features()
testing.expect(t, cant_scan(feats) == false)
base := fmt.tprintf("/tmp/envr-scan-empty-%d", os.get_pid())
os.mkdir_all(base)
base := test_temp_dir(t, "envr-scan-empty-*")
defer os.remove_all(base)
cfg := Config {
ScanConfig = ScanConfig{Matcher = "\\.env"},
scan_config = ScanConfig{matcher = "\\.env"},
}
results, ok := scan_path(base, cfg)
defer delete(results)
testing.expect(t, ok, "scan_path should succeed")
testing.expect(t, len(results) == 0, fmt.tprintf("expected 0 results, got %d", len(results)))
}
@(test)
test_scan_meets_expectations :: proc(t: ^testing.T) {
testing.expect(t, cant_scan({}), "no features should mean can't scan")
testing.expect(t, cant_scan({.Git}), "Git alone should mean can't scan")
testing.expect(t, !cant_scan({.Fd}), "having Fd should mean can scan")
testing.expect(t, !cant_scan({.Fd, .Git}), "both Fd and Git should mean can scan")
testing.expect_value(t, len(results), 0)
}
+47 -32
View File
@@ -4,42 +4,57 @@ import "core:c"
foreign import lib "system:sqlite3"
Db :: distinct rawptr
Stmt :: distinct rawptr
// TODO: Use an enum?
OK :: 0
ROW :: 100
DONE :: 101
DESERIALIZE_FREEONCLOSE :: 1
DESERIALIZE_RESIZEABLE :: 2
DESERIALIZE_FLAGS :: bit_set[DESERIALIZE_FLAG]
DESERIALIZE_FLAG :: enum u32 {
FREEONCLOSE = 1,
RESIZEABLE = 2,
READONLY = 4,
}
SERIALIZE_FLAGS :: bit_set[SERIALIZE_FLAG]
SERIALIZE_FLAG :: enum u32 {
NOCOPY = 1,
}
foreign lib {
@(link_name="sqlite3_open")
db_open :: proc(filename: cstring, ppDb: ^^rawptr) -> c.int ---
@(link_name="sqlite3_close")
db_close :: proc(db: ^rawptr) -> c.int ---
@(link_name="sqlite3_errmsg")
db_errmsg :: proc(db: ^rawptr) -> cstring ---
@(link_name="sqlite3_exec")
db_exec :: proc(db: ^rawptr, sql: cstring, callback: rawptr, callback_arg: rawptr, errmsg: ^cstring) -> c.int ---
@(link_name="sqlite3_prepare_v2")
prepare_v2 :: proc(db: ^rawptr, sql: cstring, nByte: c.int, ppStmt: ^^rawptr, pzTail: ^cstring) -> c.int ---
@(link_name="sqlite3_step")
step :: proc(stmt: ^rawptr) -> c.int ---
@(link_name="sqlite3_finalize")
finalize :: proc(stmt: ^rawptr) -> c.int ---
@(link_name="sqlite3_column_text")
column_text :: proc(stmt: ^rawptr, iCol: c.int) -> cstring ---
@(link_name="sqlite3_column_bytes")
column_bytes :: proc(stmt: ^rawptr, iCol: c.int) -> c.int ---
@(link_name="sqlite3_bind_text")
bind_text :: proc(stmt: ^rawptr, idx: c.int, val: cstring, n: c.int, destructor: rawptr) -> c.int ---
@(link_name="sqlite3_changes")
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) ---
@(link_name = "sqlite3_open")
open :: proc(filename: cstring, ppDb: ^Db) -> c.int ---
@(link_name = "sqlite3_close")
close :: proc(db: Db) -> c.int ---
@(link_name = "sqlite3_errmsg")
errmsg :: proc(db: Db) -> cstring ---
@(link_name = "sqlite3_exec")
exec :: proc(db: Db, sql: cstring, callback: rawptr, callback_arg: rawptr, errmsg: ^cstring) -> c.int ---
@(link_name = "sqlite3_prepare_v2")
prepare_v2 :: proc(db: Db, sql: cstring, nByte: c.int, ppStmt: ^Stmt, pzTail: ^cstring) -> c.int ---
@(link_name = "sqlite3_step")
step :: proc(stmt: Stmt) -> c.int ---
@(link_name = "sqlite3_finalize")
finalize :: proc(stmt: Stmt) -> c.int ---
@(link_name = "sqlite3_column_text")
column_text :: proc(stmt: Stmt, iCol: c.int) -> cstring ---
@(link_name = "sqlite3_column_bytes")
column_bytes :: proc(stmt: Stmt, iCol: c.int) -> c.int ---
@(link_name = "sqlite3_bind_text")
bind_text :: proc(stmt: Stmt, idx: c.int, val: cstring, n: c.int, destructor: rawptr) -> c.int ---
@(link_name = "sqlite3_changes")
changes :: proc(db: Db) -> c.int ---
@(link_name = "sqlite3_serialize")
serialize :: proc(db: Db, zSchema: cstring, piSize: ^i64, mFlags: SERIALIZE_FLAGS) -> [^]u8 ---
@(link_name = "sqlite3_deserialize")
deserialize :: proc(db: Db, zSchema: cstring, pData: [^]u8, szDb: i64, szBuf: i64, mFlags: DESERIALIZE_FLAGS) -> c.int ---
@(link_name = "sqlite3_malloc64")
malloc64 :: proc(n: i64) -> [^]u8 ---
@(link_name = "sqlite3_free")
free :: proc(p: rawptr) ---
}
+66 -129
View File
@@ -1,7 +1,10 @@
package main
import "base:runtime"
import "core:encoding/base64"
import "core:encoding/endian"
import "core:fmt"
import "core:mem"
import "core:os"
import "core:strings"
@@ -12,24 +15,6 @@ Ed25519Keypair :: struct {
Private: [32]u8,
}
read_wire_string :: proc(data: []u8, offset: ^int) -> (s: string, ok: bool) {
if offset^ + 4 > len(data) {
return
}
length := u32(data[offset^]) << 24 | u32(data[offset^ + 1]) << 16 |
u32(data[offset^ + 2]) << 8 | u32(data[offset^ + 3])
offset^ += 4
if offset^ + int(length) > len(data) {
return
}
s = string(data[offset^ : offset^ + int(length)])
offset^ += int(length)
ok = true
return
}
parse_ssh_public_key :: proc(pub_path: string) -> (pub: [32]u8, ok: bool) {
data, err := os.read_entire_file_from_path(pub_path, context.temp_allocator)
if err != nil {
@@ -50,20 +35,18 @@ parse_ssh_public_key :: proc(pub_path: string) -> (pub: [32]u8, ok: bool) {
return
}
offset := 0
key_type, type_ok := read_wire_string(decoded, &offset)
if !type_ok || key_type != SSH_ED25519 {
rest := decoded
key_type, type_ok := read_wire_string(&rest)
if !type_ok || string(key_type) != SSH_ED25519 {
return
}
pk_data, pk_ok := read_wire_string(decoded, &offset)
pk_data, pk_ok := read_wire_string(&rest)
if !pk_ok || len(pk_data) != 32 {
return
}
for i in 0 ..< 32 {
pub[i] = pk_data[i]
}
mem.copy_non_overlapping(&pub[0], &pk_data[0], 32)
ok = true
return
@@ -103,153 +86,107 @@ parse_ssh_private_key :: proc(priv_path: string) -> (kp: Ed25519Keypair, ok: boo
return
}
magic := "openssh-key-v1\x00"
if len(decoded) < len(magic) {
return
}
for i in 0 ..< len(magic) {
if decoded[i] != u8(magic[i]) {
return
}
}
offset := len(magic)
ciphername, cipher_ok := read_wire_string(decoded, &offset)
if !cipher_ok || ciphername != "none" {
magic :: "openssh-key-v1\x00"
if !strings.has_prefix(string(decoded), magic) {
return
}
kdfname, kdf_ok := read_wire_string(decoded, &offset)
if !kdf_ok || kdfname != "none" {
rest := decoded[len(magic):]
ciphername, cipher_ok := read_wire_string(&rest)
if !cipher_ok || string(ciphername) != "none" {
return
}
_, opts_ok := read_wire_string(decoded, &offset)
kdfname, kdf_ok := read_wire_string(&rest)
if !kdf_ok || string(kdfname) != "none" {
return
}
_, opts_ok := read_wire_string(&rest)
if !opts_ok {
return
}
if offset + 4 > len(decoded) {
return
}
num_keys := u32(decoded[offset]) << 24 | u32(decoded[offset + 1]) << 16 |
u32(decoded[offset + 2]) << 8 | u32(decoded[offset + 3])
offset += 4
if num_keys != 1 {
num_keys, nkeys_ok := read_wire_u32(&rest)
if !nkeys_ok || num_keys != 1 {
return
}
_, pub_blob_ok := read_wire_string(decoded, &offset)
_, pub_blob_ok := read_wire_string(&rest)
if !pub_blob_ok {
return
}
priv_blob, priv_blob_ok := read_wire_string(decoded, &offset)
priv_blob, priv_blob_ok := read_wire_string(&rest)
if !priv_blob_ok {
return
}
inner_offset := 0
if inner_offset + 8 > len(priv_blob) {
return
}
check1 := u32(priv_blob[inner_offset]) << 24 | u32(priv_blob[inner_offset + 1]) << 16 |
u32(priv_blob[inner_offset + 2]) << 8 | u32(priv_blob[inner_offset + 3])
inner_offset += 4
check2 := u32(priv_blob[inner_offset]) << 24 | u32(priv_blob[inner_offset + 1]) << 16 |
u32(priv_blob[inner_offset + 2]) << 8 | u32(priv_blob[inner_offset + 3])
inner_offset += 4
inner := priv_blob
if check1 != check2 {
check1, c1_ok := read_wire_u32(&inner)
check2, c2_ok := read_wire_u32(&inner)
if !c1_ok || !c2_ok || check1 != check2 {
return
}
priv_type, type_ok := read_wire_string(transmute([]u8)priv_blob, &inner_offset)
if !type_ok || priv_type != SSH_ED25519 {
priv_type, type_ok := read_wire_string(&inner)
if !type_ok || string(priv_type) != SSH_ED25519 {
return
}
pub_wire, pub_ok := read_wire_string(transmute([]u8)priv_blob, &inner_offset)
pub_wire, pub_ok := read_wire_string(&inner)
if !pub_ok || len(pub_wire) != 32 {
return
}
for i in 0 ..< 32 {
kp.Public[i] = pub_wire[i]
}
mem.copy_non_overlapping(&kp.Public[0], &pub_wire[0], 32)
priv_wire, priv_ok := read_wire_string(transmute([]u8)priv_blob, &inner_offset)
priv_wire, priv_ok := read_wire_string(&inner)
if !priv_ok || len(priv_wire) != 64 {
return
}
for i in 0 ..< 32 {
kp.Private[i] = priv_wire[i]
}
mem.copy_non_overlapping(&kp.Private[0], &priv_wire[0], 32)
ok = true
return
}
is_ed25519_key :: proc(priv_path: string) -> bool {
pub_path, _ := strings.concatenate([]string{priv_path, ".pub"}, context.temp_allocator)
_, ok := parse_ssh_public_key(pub_path)
return ok
is_ed25519_key :: proc(
priv_path: string,
) -> (
ok: bool,
err: runtime.Allocator_Error,
) #optional_allocator_error {
pub_path := strings.concatenate([]string{priv_path, ".pub"}, context.temp_allocator) or_return
_, ok = parse_ssh_public_key(pub_path)
return ok, nil
}
is_encrypted_key :: proc(priv_path: string) -> bool {
data, err := os.read_entire_file_from_path(priv_path, context.temp_allocator)
if err != nil {
return true
read_wire_string :: proc(data: ^[]u8) -> (s: []u8, ok: bool) #no_bounds_check {
if len(data^) < 4 {
return
}
length := endian.get_u32(data^[:4], .Big) or_return
data^ = data^[4:]
if !strings.contains(string(data), "BEGIN OPENSSH PRIVATE KEY") {
return true
if len(data^) < int(length) {
return
}
text := string(data)
lines := strings.split(text, "\n", context.temp_allocator)
b2: strings.Builder
strings.builder_init(&b2, context.temp_allocator)
defer strings.builder_destroy(&b2)
in_block := false
for line in lines {
trimmed := strings.trim_space(line)
if trimmed == "-----BEGIN OPENSSH PRIVATE KEY-----" {
in_block = true
continue
}
if trimmed == "-----END OPENSSH PRIVATE KEY-----" {
break
}
if in_block && len(trimmed) > 0 {
fmt.sbprintf(&b2, "%s", trimmed)
}
}
b64_str := strings.to_string(b2)
decoded, decode_err := base64.decode(b64_str, allocator = context.temp_allocator)
if decode_err != nil {
return true
}
magic := "openssh-key-v1\x00"
if len(decoded) < len(magic) {
return true
}
for i in 0 ..< len(magic) {
if decoded[i] != u8(magic[i]) {
return true
}
}
offset := len(magic)
ciphername, cipher_ok := read_wire_string(decoded, &offset)
if !cipher_ok {
return true
}
return ciphername != "none"
s = data^[:int(length)]
data^ = data^[int(length):]
ok = true
return
}
read_wire_u32 :: proc(data: ^[]u8) -> (v: u32, ok: bool) #no_bounds_check {
if len(data^) < 4 {
return
}
v = endian.get_u32(data^[:4], .Big) or_return
data^ = data^[4:]
ok = true
return
}
+10 -52
View File
@@ -1,9 +1,11 @@
#+test
package main
import "core:fmt"
import "core:os"
import "core:testing"
TEST_KEY_DIR :: "fixtures/keys"
TEST_KEY_DIR :: "fixtures" + os.Path_Separator_String + "keys"
@(test)
test_parse_ed25519_public_key :: proc(t: ^testing.T) {
@@ -44,65 +46,21 @@ test_private_key_pub_matches_public_key :: proc(t: ^testing.T) {
kp, priv_ok := parse_ssh_private_key(TEST_KEY_DIR + "/test_ed25519")
testing.expect(t, priv_ok, "expected private key to parse")
testing.expect(
t,
pub_from_pub == kp.Public,
fmt.tprintf(
"public key mismatch:\n from .pub: %v\n from priv: %v",
pub_from_pub,
kp.Public,
),
)
testing.expect_value(t, pub_from_pub, kp.Public)
}
@(test)
test_read_wire_string :: proc(t: ^testing.T) {
data := []u8{0, 0, 0, 5, u8('h'), u8('e'), u8('l'), u8('l'), u8('o'), 0, 0, 0, 0}
offset := 0
s, ok := read_wire_string(data, &offset)
buf := data
s, ok := read_wire_string(&buf)
testing.expect(t, ok, "expected read_wire_string to succeed")
testing.expect(t, s == "hello", fmt.tprintf("expected 'hello', got %q", s))
testing.expect(t, offset == 9, fmt.tprintf("expected offset 9, got %d", offset))
testing.expect_value(t, string(s), "hello")
testing.expect_value(t, len(data) - len(buf), 9)
s2, ok2 := read_wire_string(data, &offset)
s2, ok2 := read_wire_string(&buf)
testing.expect(t, ok2, "expected second read to succeed")
testing.expect(t, s2 == "", "expected empty string")
}
@(test)
test_is_encrypted_key_encrypted :: proc(t: ^testing.T) {
testing.expect(
t,
is_encrypted_key(TEST_KEY_DIR + "/test_ed25519_encrypted"),
"encrypted key should be detected as encrypted",
)
}
@(test)
test_is_encrypted_key_unencrypted :: proc(t: ^testing.T) {
testing.expect(
t,
!is_encrypted_key(TEST_KEY_DIR + "/test_ed25519"),
"unencrypted key should not be detected as encrypted",
)
}
@(test)
test_is_encrypted_key_rsa_unencrypted :: proc(t: ^testing.T) {
testing.expect(
t,
!is_encrypted_key(TEST_KEY_DIR + "/test_rsa"),
"unencrypted RSA key should not be detected as encrypted",
)
}
@(test)
test_is_encrypted_key_missing_file :: proc(t: ^testing.T) {
testing.expect(
t,
is_encrypted_key(TEST_KEY_DIR + "/nonexistent"),
"missing file should be treated as encrypted (fail-safe)",
)
testing.expect_value(t, len(s2), 0)
}
+62 -76
View File
@@ -1,89 +1,75 @@
package main
import "core:encoding/json"
import "core:fmt"
import "core:io"
import "core:strings"
import "core:text/table"
render_table :: proc(w: io.Writer, headers: []string, rows: [][]string) {
col_widths := make([dynamic]int, 0, len(headers))
for i in 0 ..< len(headers) {
append(&col_widths, strings.rune_count(headers[i]))
}
for r in rows {
for i in 0 ..< len(r) {
rw := strings.rune_count(r[i])
if i < len(col_widths) && rw > col_widths[i] {
col_widths[i] = rw
}
}
}
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
defer delete(col_widths)
hline :: proc(w: io.Writer, b: ^strings.Builder, left, mid, right: string, widths: [dynamic]int) {
strings.write_string(b, left)
for i in 0 ..< len(widths) {
for _ in 0 ..< widths[i] + 2 {
strings.write_string(b, "\u2500")
}
if i < len(widths) - 1 {
strings.write_string(b, mid)
} else {
strings.write_string(b, right)
}
}
fmt.wprintf(w, "%s\n", strings.to_string(b^), flush = false)
strings.builder_reset(b)
}
hline(w, &b, "\u250c", "\u252c", "\u2510", col_widths)
cell :: proc(b: ^strings.Builder, s: string, width: int) {
extra := len(s) - strings.rune_count(s)
fmt.sbprintf(b, " %-*s \u2502", width + extra, s)
}
strings.write_string(&b, "\u2502")
for i in 0 ..< len(headers) {
cell(&b, headers[i], col_widths[i])
}
fmt.wprintf(w, "%s\n", strings.to_string(b), flush = false)
strings.builder_reset(&b)
hline(w, &b, "\u251c", "\u253c", "\u2524", col_widths)
for r in rows {
strings.write_string(&b, "\u2502")
for i in 0 ..< len(r) {
cell(&b, r[i], col_widths[i])
}
fmt.wprintf(w, "%s\n", strings.to_string(b), flush = false)
strings.builder_reset(&b)
}
hline(w, &b, "\u2514", "\u2534", "\u2518", col_widths)
decorations := table.Decorations {
"┌",
"┬",
"┐",
"├",
"┼",
"┤",
"└",
"┴",
"┘",
"│",
"─",
}
render_json_rows :: proc(w: io.Writer, headers: []string, rows: [][]string) {
entries := make([dynamic]map[string]string, 0, len(rows), context.temp_allocator)
write_borderless_table :: proc(w: io.Writer, t: ^table.Table) {
table.build(t, ansi_aware_width)
for row in rows {
entry := make(map[string]string, len(headers), context.temp_allocator)
for i in 0 ..< len(headers) {
entry[headers[i]] = row[i]
write_table_separator :: proc(w: io.Writer, tbl: ^table.Table) {
io.write_byte(w, '\n')
}
if t.caption != "" {
table.write_text_align(
w,
colorize(.Heading, t.caption),
.Left,
0, //t.lpad,
0, //t.rpad,
t.tblw + t.nr_cols - 1 - ansi_aware_width(t.caption) - t.lpad - t.rpad,
)
io.write_byte(w, '\n')
}
write_table_separator(w, t)
for row in 0 ..< t.nr_rows {
for col in 0 ..< t.nr_cols {
table.write_table_cell(w, t, row, col)
}
io.write_byte(w, '\n')
if t.has_header_row && row == table.header_row(t) {
write_table_separator(w, t)
}
append(&entries, entry)
}
write_table_separator(w, t)
}
data, err := json.marshal(entries[:], allocator = context.temp_allocator)
if err != nil {
fmt.eprintf("Error marshaling JSON: %v\n", err)
return
ansi_aware_width :: proc(str: string) -> int #no_bounds_check {
width := 0
for i := 0; i < len(str); {
if i + 1 < len(str) && str[i] == 0x1b && str[i + 1] == '[' {
i += 2
for i < len(str) {c := str[i]; i += 1; if c >= 0x40 && c <= 0x7E {break}}
} else {
width += 1
i += 1
}
}
fmt.wprintf(w, "%s", data, flush = false)
return width
}
table_reset :: proc(t: ^table.Table) {
clear(&t.cells)
clear(&t.colw)
t.caption = ""
t.tblw = 0
t.nr_cols = 0
t.nr_rows = 0
}
+21 -177
View File
@@ -1,198 +1,42 @@
#+test
package main
import "core:encoding/json"
import "core:fmt"
import "core:strings"
import "core:testing"
@(test)
test_render_json_rows_normal :: proc(t: ^testing.T) {
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
headers := []string{"name", "path"}
rows := [][]string{{"foo", "/home/user/.env"}, {"bar", "/home/user/project/.env"}}
w := strings.to_writer(&b)
render_json_rows(w, headers, rows)
output := strings.to_string(b)
result: []map[string]string = ---
unmarshal_err := json.unmarshal_string(output, &result, allocator = context.temp_allocator)
testing.expect(
t,
unmarshal_err == nil,
fmt.tprintf("json unmarshal failed: %v\noutput was: %q", unmarshal_err, output),
)
testing.expect(t, len(result) == 2, fmt.tprintf("expected 2 rows, got %d", len(result)))
testing.expect(
t,
result[0]["name"] == "foo",
fmt.tprintf("expected name=foo, got %q", result[0]["name"]),
)
testing.expect(t, result[0]["path"] == "/home/user/.env")
testing.expect(t, result[1]["name"] == "bar")
testing.expect(t, result[1]["path"] == "/home/user/project/.env")
test_ansi_aware_width_plain_ascii :: proc(t: ^testing.T) {
testing.expect_value(t, ansi_aware_width("hello"), 5)
}
@(test)
test_render_json_rows_special_chars :: proc(t: ^testing.T) {
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
headers := []string{"key", "value"}
rows := [][]string {
{"quote", `has "double quotes"`},
{"backslash", `path\to\file`},
{"newline", "line1\nline2"},
{"mixed", `a "b" c\nd`},
}
w := strings.to_writer(&b)
render_json_rows(w, headers, rows)
output := strings.to_string(b)
result: []map[string]string = ---
unmarshal_err := json.unmarshal(
transmute([]byte)output,
&result,
allocator = context.temp_allocator,
)
testing.expect(
t,
unmarshal_err == nil,
fmt.tprintf("json unmarshal failed: %v\noutput was: %q", unmarshal_err, output),
)
testing.expect(t, len(result) == 4)
testing.expect(
t,
result[0]["value"] == `has "double quotes"`,
fmt.tprintf("got %q", result[0]["value"]),
)
testing.expect(t, result[1]["value"] == `path\to\file`)
testing.expect(t, result[2]["value"] == "line1\nline2")
testing.expect(t, result[3]["value"] == `a "b" c\nd`)
test_ansi_aware_width_empty :: proc(t: ^testing.T) {
testing.expect_value(t, ansi_aware_width(""), 0)
}
@(test)
test_render_json_rows_empty :: proc(t: ^testing.T) {
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
headers := []string{"name"}
rows: [][]string
w := strings.to_writer(&b)
render_json_rows(w, headers, rows)
output := strings.to_string(b)
result: []map[string]string = ---
unmarshal_err := json.unmarshal_string(output, &result, allocator = context.temp_allocator)
testing.expect(
t,
unmarshal_err == nil,
fmt.tprintf("json unmarshal failed: %v\noutput was: %q", unmarshal_err, output),
)
testing.expect(t, len(result) == 0)
test_ansi_aware_width_with_color_codes :: proc(t: ^testing.T) {
colored := colorize(.Heading, "Directory", disable = false)
testing.expect_value(t, ansi_aware_width(colored), 9)
}
@(test)
test_render_table_normal :: proc(t: ^testing.T) {
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
headers := []string{"Name", "Path"}
rows := [][]string{{"foo", "/home/user/.env"}, {"bar", "/home/user/project/.env"}}
w := strings.to_writer(&b)
render_table(w, headers, rows)
output := strings.to_string(b)
expected := `
Name Path
foo /home/user/.env
bar /home/user/project/.env
`
testing.expect(
t,
output == expected,
fmt.tprintf(
"table output mismatch\n--- expected ---\n%s\n--- got ---\n%s\n",
expected,
output,
),
)
test_ansi_aware_width_multibyte :: proc(t: ^testing.T) {
testing.expect_value(t, ansi_aware_width("\u2713 Available"), 13)
testing.expect_value(t, ansi_aware_width("\u2717 Missing"), 11)
}
@(test)
test_render_table_empty :: proc(t: ^testing.T) {
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
headers := []string{"Name"}
rows: [][]string
w := strings.to_writer(&b)
render_table(w, headers, rows)
output := strings.to_string(b)
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_render_table_unicode :: proc(t: ^testing.T) {
b: strings.Builder
strings.builder_init(&b)
defer strings.builder_destroy(&b)
headers := []string{"Status", "Detail"}
rows := [][]string{{"\u2713 Available", "ok"}, {"\u2717 Missing", "fail"}}
w := strings.to_writer(&b)
render_table(w, headers, rows)
output := strings.to_string(b)
expected := `
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,
),
test_ansi_aware_width_multiple_escape_sequences :: proc(t: ^testing.T) {
colored := strings.concatenate(
{
colorize(.Heading, "a", disable = false),
colorize(.Heading, "b", disable = false),
colorize(.Heading, "c", disable = false),
},
context.temp_allocator,
)
testing.expect_value(t, ansi_aware_width(colored), 3)
}
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
0.3.0
1.0.0