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
change vqsqoyyqmolpooryrslqnrkkqktwqxwl
commit 7ba9962b27c18f8b16fdee34f663f110e0123b44
author Alpha Chen <alpha@kejadlen.dev>
date
parent nlwzmxwr
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