zenops 0.20.0

Declarative system configuration management for shell config and dotfiles.
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

```bash
just                                 # inner loop: fmt-check + clippy
just fix                             # apply fmt and clippy autofixes
just test [FILTER]                   # run all tests (lib/bin/integration/examples + doctests), optionally filtered
just ci                              # PR-time gate: fmt-check + clippy + test + doc (same as the GitHub Action)
just e2e                             # cross-platform docker matrix (opt-in; docker required)
just prerelease                      # full release gate: ci + release-build + package + e2e
```

The Justfile is the single source of truth for "what runs locally" and "what CI runs" — the GitHub Action invokes the same atomic recipes (`fmt-check`, `clippy`, `test`, `doc`) that `just ci` composes. Drop into raw `cargo …` when you want something the Justfile doesn't cover.

The `bump-version` skill runs `just prerelease` automatically before committing the version bump and refuses to tag if anything fails. Run it directly any time you want the same gate outside the skill.

## Architecture

ZenOps is a Rust (edition 2024) system configuration management tool. It reads a declarative TOML config from `~/.config/zenops/config.toml` and manages shell config and dotfiles on the local system.

**Workspace layout:**
- `src/` — main binary crate
- `crates/zenops-expand` — `ExpandStr` newtype for strings with `${name}` placeholders that must be `.expand(lookup)`ed before use
- `crates/zenops-safe-relative-path` — custom path type that prevents `..` traversal; used throughout for all managed file paths
- `crates/zenops-safe-relative-path-macros` — `srpath!()` compile-time macro
- `crates/zenops-safe-relative-path-validator` — shared validation logic

## Conventions

- `SmolStr`: use `SmolStr::new_static(s)` when `s` is `&'static str` (string literals, `std::env::consts::*`, etc.); reserve `SmolStr::new` for runtime-owned values. `new_static` avoids the allocation check and stores the literal directly.
- License files in subcrates: each published crate needs `LICENSE-APACHE` and `LICENSE-MIT` at its root (cargo packages each crate independently), but they must be **symlinks** to the workspace-root files (`ln -s ../../LICENSE-APACHE LICENSE-APACHE`), not real copies. Cargo resolves symlinks when building the `.crate` tarball, so each published crate ships the license text without duplicating bytes on disk. When adding a new subcrate, create the symlinks — do not copy the files.
- Per-crate versioning: every crate has its own explicit `version = "X.Y.Z"` in its `[package]` block — the workspace does not share a version. The root `[workspace.dependencies]` pins each internal crate with `version = "X.Y.Z"`; bumping an internal crate means editing both its own `[package] version` and that pin. Release tags are `<crate>-v<X.Y.Z>` (e.g. `zenops-expand-v0.4.3`). The `bump-version` skill automates this.
- Error handling:
  - Modules with more than ~2 fallible-error kinds get their own `thiserror::Error` enum (e.g. `src/config/pkg/error.rs`, `src/utils/which.rs`), wrapped into the crate-level `Error` via `#[from]` + `#[error(transparent)]`.
  - Don't swallow errors. When an error IS expected, match the *specific* inner variant and bubble the rest. Canonical example: `src/utils/which.rs::get_path` matches `which::Error::CannotFindBinaryPath` / `CannotGetCurrentDirAndPathListEmpty` as "not found"; `CannotCanonicalize` bubbles up.
  - Use `Path::try_exists()` not `Path::exists()` — `exists()` swallows IO errors.
  - No `.unwrap()` / `.expect()` in production code paths. If an invariant is real, encode it in the type system (cf. commit `6ef5db9` "Eliminate unreachable!() calls by encoding invariants in types").

**Command flow (`src/`):**
1. `main.rs` — clap CLI, calls into `lib.rs`
2. `lib.rs` — dispatches to one of three commands: `Apply`, `Status`, `Repo`
3. `config.rs` — loads and deserializes `config.toml`
4. `config_files.rs` — applies config files: symlinks or generates content under `~/.config/` or `~/`
5. `git.rs` — checks git status of zenops config repo; also passes through raw git subcommands
6. `output.rs` — `Output` trait abstraction for reporting actions (current impl: `Log`)

**Config format (TOML):**
```toml
[shell]
type = "bash"
[shell.environment]
KEY = "value"
[shell.alias]
alias = "command"

[[configs]]
type = ".config"               # or "home"
name = "app"
source = "configs/app"         # relative path in zenops repo
symlinks = ["config.toml"]     # files to symlink (others are generated)
```

**Integration tests** (`tests/basics.rs`) use `tempfile` for isolation and spin up minimal git repos via `xshell`. `TestEnv` in `tests/test_env.rs` provides helpers for file creation, git init (with `gpgsign=false`), and `ConfigFilePath` assertions.

## Workflow

Work either comes from a GitHub issue or directly from chat. Issues are an external surface — third-party bug reports, feature requests, or questions — so when the user asks me to do something directly in conversation, we plan locally and don't create an issue for it. If something out of scope surfaces mid-PR, I ask whether to file it as an issue or skip it; there are no in-repo notes.

Every change goes on a branch, never directly on `main`. Name branches `<type>/<slug>` (e.g. `fix/parse-toml-error`, `feat/json-output`), or `<type>/issue-<n>-<slug>` when tied to a GitHub issue. Type prefixes are `fix`, `feat`, `refactor`, `docs`, `chore`, or `test`. Within a branch, each logical unit gets its own commit, made *as soon as the unit is done* — don't batch up a multi-change working tree to split after the fact; overlapping edits across logical units make a clean retroactive split risky. Commit messages exist for `git log` skimmability: subject is an at-a-glance overview, with an optional short paragraph below when there's a "why" worth carrying or cross-commit framing to explain. Don't restate what the diff shows; the details live in the diff.

Before pushing, run `just ci` — the same recipe runs in the GitHub Action, so a green local run means CI will be green too. For changes that could affect cross-platform behavior (shell config, init/apply paths, host detection), also run `just e2e`. Run `/code-review` against the branch and address anything it flags. The aim is for CI red to be vanishingly unlikely, so PRs always open ready for review — never as drafts.

Open the PR with `gh pr create`, link the issue via `Closes #N` if applicable, and report the URL back. Address review comments as new commits on the branch; don't force-push unless asked. Once gates pass (approval, plus the worker gate once it lands), I merge the PR myself. Always use a **merge commit** — never **squash**, which breaks commit-signature verification (`main` requires signed commits), and never **rebase-merge**, which replays commits without the PR reference and loses the link back to the PR in `main`'s history. The merge commit references the PR and preserves the branch's signed commits. The branch is deleted on merge.

Versioning and publishing stay outside this loop. I don't bump crate versions unless the user is about to publish, and I don't run publish/release commands — those belong to the user.