Scaffold project infrastructure: justfile, CI, integration tests, AGENTS.md
Existing justfile only had check/fmt/clippy. Added coverage (grcov,
not yet enforcing 100%), install, and a default `all` target. Added
GitHub Actions CI and CalVer release workflows, an assert_cmd
integration test, and an AGENTS.md documenting the project for agents.
Assisted-by: Claude Opus 4.6 via pi
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..2cef060
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,17 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ ci:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - run: rustup component add clippy rustfmt llvm-tools
+ - run: cargo install grcov
+ - run: cargo install just
+ - run: cargo fmt --check
+ - run: just clippy coverage
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..b8e717b
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,46 @@
+name: Release
+
+on:
+ workflow_dispatch:
+ workflow_run:
+ workflows: [CI]
+ types: [completed]
+ branches: [main]
+
+permissions:
+ contents: write
+
+jobs:
+ build:
+ if: >-
+ github.event_name == 'workflow_dispatch'
+ || github.event.workflow_run.conclusion == 'success'
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Calculate version
+ id: version
+ run: |
+ CALVER=$(date -u +"%Y-%m-%d")
+ SHORT_SHA=$(git rev-parse --short HEAD)
+ echo "version=${CALVER}+${SHORT_SHA}" >> $GITHUB_OUTPUT
+
+ - name: Build
+ run: |
+ cargo build --release -p frork-cli
+ tar -czf frork-aarch64-apple-darwin.tar.gz -C target/release frork
+
+ - name: Publish
+ run: |
+ VERSION="${{ steps.version.outputs.version }}"
+ git tag "v${VERSION}"
+ git push origin "v${VERSION}"
+ gh release create "v${VERSION}" \
+ --title "v${VERSION}" \
+ --generate-notes \
+ frork-aarch64-apple-darwin.tar.gz
+ env:
+ GH_TOKEN: ${{ github.token }}
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..3232066
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,64 @@
+# Agents
+
+## Project
+
+Flork (frork) is a Fennel-based configuration management tool inspired by [bork](https://bork.sh/), rewritten in Rust. It evaluates declarative Fennel scripts that describe the desired state of a system (symlinks, directories, git repos, Homebrew packages) and can check status or satisfy (install/fix) each assertion.
+
+## Repository layout
+
+```
+Cargo.toml # Workspace root (frork-cli, frork-lib, frork-lua)
+Dockerfile # Ramekin agent container (Node.js + pi + Rust nightly)
+frork-cli/
+ Cargo.toml # Binary crate — the `frork` CLI
+ fennel-1.6.0.lua # Vendored Fennel compiler
+ src/
+ main.rs # Entrypoint, clap CLI, Lua/Fennel setup
+ lib.rs # Library root — re-exports modules
+ assertions.rs # Assertion types (symlink, directory, git, brew, lua)
+ errors.rs # thiserror enum (FrorkError)
+ utils.rs # Shell helpers, path expansion, Lua bindings
+ tests/
+ cli.rs # Integration tests via assert_cmd
+frork-lib/
+ Cargo.toml # Shared library crate (currently empty)
+ src/lib.rs
+frork-lua/
+ Cargo.toml # Lua C module crate (cdylib)
+ src/lib.rs # mlua module exposing frork to Lua
+justfile # Local dev tasks (fmt, check, clippy, coverage)
+.github/workflows/ # CI (fmt + clippy + coverage) and CalVer release
+```
+
+## Build and test
+
+```sh
+cargo check -p frork-cli # Type-check the CLI (workspace has mlua feature conflict)
+cargo fmt --all # Format all code
+cargo clippy -p frork-cli # Lint
+cargo test -p frork-cli # Run tests
+```
+
+Or use `just` which runs fmt, clippy, and coverage together:
+
+```sh
+just
+```
+
+**Note:** `cargo check --workspace` fails due to mutually exclusive mlua features (`vendored` in frork-cli vs `module` in frork-lua). Check crates individually.
+
+## Conventions
+
+- **Rust edition 2024**, resolver v3 workspace.
+- Error handling: `color-eyre` in the binary, `thiserror` in library code.
+- Logging uses `tracing` with `tracing-subscriber`. Use `tracing::info`, `tracing::debug`, etc. — not `println!` for diagnostic output.
+- Lua integration via `mlua` with vendored Lua 5.4. Fennel compiler is vendored as a Lua source file.
+- All CI checks must pass: `cargo fmt --all --check`, `cargo clippy`, `cargo test`.
+
+## Architecture notes
+
+- **Assertion model:** Each assertion type implements the `AssertionType` trait (`status`, `install`, `upgrade`, `remove`). Status returns `Ok`, `Missing`, or `ConflictUpgrade`.
+- **Lua/Fennel bridge:** `setup_lua` creates a Lua VM, loads the Fennel compiler, and registers a `frork` module with `ok` (assert) and `register` (define custom assertion types) functions.
+- **Custom assertions:** Fennel scripts can register new assertion types via `frork.register(name, {status=fn, install=fn})` — these become `LuaAssertionType` values dispatched through the same trait.
+- **Path expansion:** `ExpandedPath` handles `~` and `$ENV_VAR` expansion at the Lua/Rust boundary.
+- **frork-lua vs frork-cli:** frork-lua builds a `cdylib` for use as a standalone Lua module (`require("frork")`). frork-cli embeds Lua/Fennel and is the primary interface. The two crates cannot be built together due to conflicting mlua features.
diff --git a/Cargo.lock b/Cargo.lock
index 417f55c..1860b4d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -76,6 +76,21 @@ dependencies = [
"windows-sys 0.60.2",
]
+[[package]]
+name = "assert_cmd"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a686bbee5efb88a82df0621b236e74d925f470e5445d3220a5648b892ec99c9"
+dependencies = [
+ "anstyle",
+ "bstr",
+ "libc",
+ "predicates",
+ "predicates-core",
+ "predicates-tree",
+ "wait-timeout",
+]
+
[[package]]
name = "autocfg"
version = "1.5.0"
@@ -110,6 +125,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4"
dependencies = [
"memchr",
+ "regex-automata",
"serde",
]
@@ -202,6 +218,12 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+[[package]]
+name = "difflib"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
+
[[package]]
name = "either"
version = "1.15.0"
@@ -245,12 +267,27 @@ dependencies = [
"once_cell",
]
+[[package]]
+name = "fastrand"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
+
[[package]]
name = "find-msvc-tools"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127"
+[[package]]
+name = "float-cmp"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "frork"
version = "0.1.0"
@@ -264,12 +301,15 @@ dependencies = [
name = "frork-cli"
version = "0.1.0"
dependencies = [
+ "assert_cmd",
"clap",
"color-eyre",
"frork-lib",
"mlua",
+ "predicates",
"regex",
"serde",
+ "tempfile",
"thiserror",
"tracing",
"tracing-subscriber",
@@ -279,6 +319,18 @@ dependencies = [
name = "frork-lib"
version = "0.1.0"
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+]
+
[[package]]
name = "gimli"
version = "0.32.3"
@@ -413,6 +465,12 @@ dependencies = [
"syn",
]
+[[package]]
+name = "normalize-line-endings"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"
+
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -502,6 +560,36 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+[[package]]
+name = "predicates"
+version = "3.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe"
+dependencies = [
+ "anstyle",
+ "difflib",
+ "float-cmp",
+ "normalize-line-endings",
+ "predicates-core",
+ "regex",
+]
+
+[[package]]
+name = "predicates-core"
+version = "1.0.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144"
+
+[[package]]
+name = "predicates-tree"
+version = "1.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2"
+dependencies = [
+ "predicates-core",
+ "termtree",
+]
+
[[package]]
name = "proc-macro2"
version = "1.0.103"
@@ -520,6 +608,12 @@ dependencies = [
"proc-macro2",
]
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -673,6 +767,25 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "tempfile"
+version = "3.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
+dependencies = [
+ "fastrand",
+ "getrandom",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "termtree"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
+
[[package]]
name = "thiserror"
version = "2.0.17"
@@ -793,6 +906,24 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+[[package]]
+name = "wait-timeout"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "wasip2"
+version = "1.0.2+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
+dependencies = [
+ "wit-bindgen",
+]
+
[[package]]
name = "which"
version = "8.0.0"
@@ -898,3 +1029,9 @@ name = "winsafe"
version = "0.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
+
+[[package]]
+name = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
diff --git a/frork-cli/Cargo.toml b/frork-cli/Cargo.toml
index ea716e7..3474ddf 100644
--- a/frork-cli/Cargo.toml
+++ b/frork-cli/Cargo.toml
@@ -16,4 +16,9 @@ regex = "*"
serde = { version = "*", features = ["derive"] }
thiserror = "*"
tracing = "*"
-tracing-subscriber = "*"
\ No newline at end of file
+tracing-subscriber = "*"
+
+[dev-dependencies]
+assert_cmd = "*"
+predicates = "*"
+tempfile = "*"
\ No newline at end of file
diff --git a/frork-cli/tests/cli.rs b/frork-cli/tests/cli.rs
new file mode 100644
index 0000000..32810cc
--- /dev/null
+++ b/frork-cli/tests/cli.rs
@@ -0,0 +1,11 @@
+use assert_cmd::Command;
+use assert_cmd::cargo::cargo_bin_cmd;
+
+fn cmd() -> Command {
+ Command::from(cargo_bin_cmd!("frork"))
+}
+
+#[test]
+fn shows_help() {
+ cmd().arg("--help").assert().success();
+}
diff --git a/justfile b/justfile
new file mode 100644
index 0000000..d9b3eab
--- /dev/null
+++ b/justfile
@@ -0,0 +1,50 @@
+default: all
+
+fmt:
+ cargo fmt --all
+
+check:
+ cargo check --workspace
+
+clippy:
+ cargo clippy --workspace -- -D warnings
+
+coverage:
+ #!/usr/bin/env bash
+ set -euo pipefail
+ export RUSTFLAGS="-Cinstrument-coverage"
+ export CARGO_TARGET_DIR="target/coverage"
+ export LLVM_PROFILE_FILE="target/coverage/profraw/%p-%m.profraw"
+ rm -rf target/coverage
+ cargo test --workspace -q
+ REPORT=$(grcov target/coverage/profraw \
+ --binary-path ./target/coverage/debug/ \
+ -s . \
+ -t covdir \
+ --ignore-not-existing \
+ --keep-only 'src/**' \
+ --ignore 'src/bin/**' \
+ --excl-line 'cov-excl-line' \
+ --excl-start 'cov-excl-start' \
+ --excl-stop 'cov-excl-stop')
+ echo "$REPORT" | jq -r '
+ def files:
+ to_entries[] | .value |
+ if .children then .children | files
+ else "\(.name): \(.coveragePercent)% (\(.linesCovered)/\(.linesTotal))"
+ end;
+ .children | files
+ '
+ COVERAGE=$(echo "$REPORT" | jq '.coveragePercent')
+ echo ""
+ echo "Total: ${COVERAGE}%"
+ # TODO: enforce 100% coverage
+ # if [ "$(echo "$COVERAGE < 100" | bc -l)" -eq 1 ]; then
+ # echo "ERROR: Coverage is below 100%"
+ # exit 1
+ # fi
+
+all: fmt clippy coverage
+
+install:
+ cargo install --locked --path frork-cli