From c8a4cbed2835f850a32c0d73c0fc561de219f536 Mon Sep 17 00:00:00 2001 From: Ofek Gabay Date: Thu, 24 Sep 2026 10:04:20 +0300 Subject: [PATCH 1/4] feat: support global checklists shared by every project Checklists in the global steplock directory ($STEPLOCK_GLOBAL_DIR, else $XDG_CONFIG_HOME/steplock, else ~/.config/steplock) now apply to every project, including projects without a .steplock/ directory. - Project checklists run first, then global checklists. - A project checklist with the same name overrides the global one; an empty directory with that name turns it off. - Global session state and audit events live in the global directory. - New CLI: `steplock init --global`, `steplock clean --global`; `steplock validate` also checks global checklists. - New library API: `run_with_global`, `global_steplock_dir`. - Promote clippy::manual_let_else to deny. Co-Authored-By: Claude Opus 5.5 (1M context) --- Architecture.md | 10 ++ CHANGELOG.md | 3 + Installation.md | 20 +++- README.md | 28 +++++ core/Cargo.toml | 1 + core/src/bin/main.rs | 137 +++++++++++++++++------- core/src/global_config.rs | 120 +++++++++++++++++++++ core/src/lib.rs | 5 +- core/src/run.rs | 187 ++++++++++++++++++++++----------- core/tests/cli.rs | 145 +++++++++++++++++++++++++ core/tests/gate_integration.rs | 150 ++++++++++++++++++++++++++ 11 files changed, 703 insertions(+), 103 deletions(-) create mode 100644 core/src/global_config.rs diff --git a/Architecture.md b/Architecture.md index a50967b..6ebca9a 100644 --- a/Architecture.md +++ b/Architecture.md @@ -211,6 +211,16 @@ steplock writes to two channels that don't touch the hook stdin/stdout protocol: When two checklist directories both match the same event, steplock processes them in alphabetical order (sorted by directory name). The first incomplete checklist blocks. Once it reaches `[*]`, the next checklist's first state blocks on the subsequent invocation. +### Global checklists + +steplock evaluates two steplock directories: the project `.steplock/` and the global directory (`$STEPLOCK_GLOBAL_DIR`, else `$XDG_CONFIG_HOME/steplock`, else `~/.config/steplock`). Both use the same layout. + +- **Order** — all project checklists first, then global checklists. The first incomplete match blocks. +- **Override** — a global checklist is skipped when the project has a checklist directory with the same name. This lets a project replace a global gate, or turn it off with an empty directory. +- **State** — each checklist stores sessions and audit events in the directory it came from. Global session state lives in the global directory, so no files are written into projects that have no `.steplock/`. +- **Same directory** — when the global directory resolves to the project `.steplock/` (for example, `STEPLOCK_GLOBAL_DIR=~/.steplock` and the project is `~`), it is evaluated once. +- **`session:stop`** — cleans the session directory in both locations. + ### Idempotent ack If `ack.sh` runs when the session is already complete or `current_state` is null (e.g. agent ran it twice), it exits 0 with a message and makes no writes: diff --git a/CHANGELOG.md b/CHANGELOG.md index bab2b21..fb343fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Global checklists: `$STEPLOCK_GLOBAL_DIR`, `$XDG_CONFIG_HOME/steplock` or `~/.config/steplock` holds checklists that apply to every project; project checklists with the same name override them +- `steplock init --global` and `steplock clean --global`; `steplock validate` also checks global checklists +- `run_with_global` and `global_steplock_dir` library API - `steplock init` command creates `.steplock/checklists/` and `.gitignore` skeleton - `session:stop` event cleans up the session directory so the checklist resets - `allow_preview_request` config option generates a `preview.sh` showing checklist progress diff --git a/Installation.md b/Installation.md index f5157ba..861b55b 100644 --- a/Installation.md +++ b/Installation.md @@ -149,6 +149,8 @@ In your project's `.claude/settings.json`: } ``` +To gate every project, put the same block in `~/.claude/settings.json` and add your checklists to the global steplock directory (see [Global checklists](#global-checklists)). + ### Cursor / Windsurf / Cline / Amp Follow your tool's hook registration docs and point the hook command at `steplock`. polyhook normalises the event format — no per-tool changes needed. @@ -174,11 +176,25 @@ cat .steplock/audit.log --- +## Global checklists + +Global checklists apply to every project. Create the global steplock directory with a sample checklist: + +```sh +steplock init --global +# steplock: initialized /Users/you/.config/steplock/checklists +``` + +The directory is `$STEPLOCK_GLOBAL_DIR`, else `$XDG_CONFIG_HOME/steplock`, else `~/.config/steplock`. Project checklists run first. A project checklist with the same name replaces the global one. Set `STEPLOCK_GLOBAL_DIR=""` to turn global checklists off. + +--- + ## Other commands ```sh -steplock validate # check every config.toml / flow.mmd under .steplock/checklists/ for errors -steplock clean # remove all session state under .steplock/sessions/ (checklists restart fresh) +steplock validate # check every config.toml / flow.mmd in .steplock/checklists/ and the global checklists/ +steplock clean # remove all session state under .steplock/sessions/ (checklists restart fresh) +steplock clean --global # remove all session state in the global steplock directory ``` --- diff --git a/README.md b/README.md index 71b28a2..5a15ecf 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,34 @@ stateDiagram-v2 See [`examples/git-push-quality-gate/`](examples/git-push-quality-gate/) for a complete working example. +### Global checklists + +A global checklist applies to every project, including projects with no `.steplock/`. Use it for gates you want everywhere, such as a check before every `git push`. + +Global checklists live in the global steplock directory, which uses the same layout as `.steplock/`: + +``` +~/.config/steplock/ +└── checklists/ + └── git-push-quality-gate/ + ├── config.toml + └── flow.mmd +``` + +steplock finds the global directory in this order: + +1. `$STEPLOCK_GLOBAL_DIR`. Set it to an empty string to turn global checklists off. +2. `$XDG_CONFIG_HOME/steplock`, when `XDG_CONFIG_HOME` is an absolute path. +3. `~/.config/steplock`. + +Rules: + +- Project checklists run first. Global checklists run after them, in alphabetical order. +- A project checklist with the same directory name replaces the global one. An empty `.steplock/checklists//` directory turns that global checklist off for the project. +- Session state and the audit log for global checklists are written to the global directory, not to the project. + +Run `steplock init --global` to create the directory with a sample checklist. Register the hook once in your user-level tool settings (for Claude Code, `~/.claude/settings.json`) so it runs in every project. + --- ## Editor support diff --git a/core/Cargo.toml b/core/Cargo.toml index 1fe8027..0746a4a 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -82,6 +82,7 @@ unwrap_used = "deny" wildcard_imports = "deny" enum_glob_use = "deny" single_match_else = "deny" +manual_let_else = "deny" [dependencies] cel-interpreter = "0.10" diff --git a/core/src/bin/main.rs b/core/src/bin/main.rs index 97f54a2..42adb8a 100644 --- a/core/src/bin/main.rs +++ b/core/src/bin/main.rs @@ -8,7 +8,7 @@ use std::path::{Path, PathBuf}; use std::process; use polyhook::parse; -use steplock::{run, HookEvent, HookResponse}; +use steplock::{global_steplock_dir, run_with_global, HookEvent, HookResponse}; fn main() { let args: Vec = env::args().skip(1).collect(); @@ -26,10 +26,16 @@ fn main() { process::exit(1); } } + [cmd, flag] if cmd == "init" && flag == "--global" => { + if let Err(e) = init_steplock_dir(&require_global_dir(), false) { + eprintln!("steplock: init failed: {e}"); + process::exit(1); + } + } [cmd] if cmd == "validate" => { let dir = env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let root = find_repo_root_from(&dir).unwrap_or(dir); - match run_validate(&root) { + match run_validate(&root, global_steplock_dir().as_deref()) { Ok(true) => {} Ok(false) => process::exit(1), Err(e) => { @@ -44,6 +50,12 @@ fn main() { process::exit(1); } } + [cmd, flag] if cmd == "clean" && flag == "--global" => { + if let Err(e) = clean_sessions(&require_global_dir()) { + eprintln!("steplock: clean failed: {e}"); + process::exit(1); + } + } [] => run_hook(), _ => { eprintln!("steplock: unknown arguments"); @@ -62,45 +74,82 @@ Stateful quality gate for AI coding agents. USAGE: steplock Read hook event from stdin and respond (used by polyhook) steplock init Create .steplock/checklists/ in the current directory - steplock validate Check all checklist configs for errors + steplock init --global Create checklists/ in the global steplock directory + steplock validate Check all project and global checklist configs for errors steplock clean Remove all session state (forces checklists to restart) + steplock clean --global + Remove all session state in the global steplock directory steplock --version Print version CHECKLIST FILES: .steplock/checklists//config.toml Gate trigger and reset configuration .steplock/checklists//flow.mmd Mermaid stateDiagram-v2 checklist flow +GLOBAL CHECKLISTS: + Checklists in /checklists// apply to every project. They run after + the project checklists. A project checklist with the same name replaces the global one. + is $STEPLOCK_GLOBAL_DIR, else $XDG_CONFIG_HOME/steplock, else + ~/.config/steplock. Set STEPLOCK_GLOBAL_DIR=\"\" to turn global checklists off. + For more information: https://github.com/polyhook/steplock", env!("CARGO_PKG_VERSION") ); } -/// Validate all checklists in `.steplock/checklists/`. Returns `Ok(true)` if all valid, -/// `Ok(false)` if any checklist failed validation (errors already printed), or `Err` on I/O. -fn run_validate(repo_root: &Path) -> io::Result { - let checklists_dir = repo_root.join(".steplock").join("checklists"); +/// Validate all checklists in `.steplock/checklists/` and in the global steplock directory. +/// Returns `Ok(true)` if all valid, `Ok(false)` if any checklist failed validation (errors +/// already printed), or `Err` on I/O. +fn run_validate(repo_root: &Path, global_dir: Option<&Path>) -> io::Result { + let project_ok = validate_dir(&repo_root.join(".steplock").join("checklists"), "")?; + let global_ok = match global_dir { + Some(global) => validate_dir(&global.join("checklists"), "global")?, + None => true, + }; + Ok(project_ok && global_ok) +} + +/// Validate one `checklists/` directory. `scope` names it in messages (`""` or `"global"`). +fn validate_dir(checklists_dir: &Path, scope: &str) -> io::Result { + let shown = checklists_dir.display(); + let (words, label_prefix) = if scope.is_empty() { + (String::new(), String::new()) + } else { + (format!("{scope} "), format!("{scope}:")) + }; if !checklists_dir.exists() { - println!("steplock: no .steplock/checklists/ found"); + println!("steplock: no {words}checklists found at {shown}"); return Ok(true); } - let errors = steplock::validate_checklists(&checklists_dir); + let errors = steplock::validate_checklists(checklists_dir); if errors.is_empty() { - println!("steplock: all checklists valid"); + println!("steplock: all {words}checklists valid ({shown})"); Ok(true) } else { for (label, err) in &errors { - eprintln!("steplock: [{label}] error: {err}"); + eprintln!("steplock: [{label_prefix}{label}] error: {err}"); } Ok(false) } } +/// Global steplock directory, or exit with an error when it is disabled or unknown. +fn require_global_dir() -> PathBuf { + global_steplock_dir().unwrap_or_else(|| { + eprintln!( + "steplock: no global steplock directory \ + (set STEPLOCK_GLOBAL_DIR, XDG_CONFIG_HOME or HOME)" + ); + process::exit(1); + }) +} + fn run_hook() { let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let repo_root = find_repo_root_from(&cwd).unwrap_or(cwd); - let response = match run_app(io::stdin(), &repo_root) { + let global_dir = global_steplock_dir(); + let response = match run_app(io::stdin(), &repo_root, global_dir.as_deref()) { Ok(r) => r, Err(e) => { eprintln!("{e}"); @@ -125,22 +174,30 @@ const SAMPLE_FLOW: &str = "stateDiagram-v2\n [*] --> tests_pass\n tests_pa /// Create `.steplock/checklists/` and a `.steplock/.gitignore` in `dir`. /// Also writes a ready-to-use sample checklist so `git push` is blocked immediately. fn run_init(dir: &Path) -> io::Result<()> { - let checklists_dir = dir.join(".steplock").join("checklists"); + init_steplock_dir(&dir.join(".steplock"), true) +} + +/// Create `checklists/` with a sample checklist in `steplock_dir`. +/// With `gitignore`, also writes a `.gitignore` for session state and the audit log. +fn init_steplock_dir(steplock_dir: &Path, gitignore: bool) -> io::Result<()> { + let checklists_dir = steplock_dir.join("checklists"); if checklists_dir.exists() { - println!("steplock: .steplock/checklists/ already exists"); + println!("steplock: {} already exists", checklists_dir.display()); return Ok(()); } fs::create_dir_all(&checklists_dir)?; - fs::write( - dir.join(".steplock").join(".gitignore"), - "sessions/\naudit.log\n", - )?; + if gitignore { + fs::write(steplock_dir.join(".gitignore"), "sessions/\naudit.log\n")?; + } let sample_dir = checklists_dir.join("example-gate"); fs::create_dir_all(&sample_dir)?; fs::write(sample_dir.join("config.toml"), SAMPLE_CONFIG)?; fs::write(sample_dir.join("flow.mmd"), SAMPLE_FLOW)?; - println!("steplock: initialized .steplock/checklists/"); - println!("A sample checklist was written to .steplock/checklists/example-gate/."); + println!("steplock: initialized {}", checklists_dir.display()); + println!( + "A sample checklist was written to {}.", + sample_dir.display() + ); println!("It will block `git push` until two quality checks are acknowledged."); println!("Edit config.toml and flow.mmd to customize it, or add more checklists."); Ok(()) @@ -156,7 +213,11 @@ fn run_clean(dir: &Path) -> io::Result<()> { println!("steplock: no .steplock/ directory found — nothing to clean"); return Ok(()); }; - let steplock_dir = root.join(".steplock"); + clean_sessions(&root.join(".steplock")) +} + +/// Remove every session directory and the fallback id under `/sessions/`. +fn clean_sessions(steplock_dir: &Path) -> io::Result<()> { let sessions_dir = steplock_dir.join("sessions"); if !sessions_dir.exists() { println!("steplock: no sessions to clean"); @@ -182,7 +243,11 @@ fn run_clean(dir: &Path) -> io::Result<()> { /// Parse the hook event from `reader`, run the gate, and return the polyhook response. /// Returns `Err(message)` when input is unreadable or the gate engine fails. -fn run_app(mut reader: impl Read, repo_root: &Path) -> Result { +fn run_app( + mut reader: impl Read, + repo_root: &Path, + global_dir: Option<&Path>, +) -> Result { let mut bytes = Vec::new(); reader .read_to_end(&mut bytes) @@ -193,7 +258,7 @@ fn run_app(mut reader: impl Read, repo_root: &Path) -> Result Ok(polyhook::HookResponse::block(&message)), Ok(_) => Ok(polyhook::HookResponse::approve()), Err(e) => Err(format!("steplock: error: {e}")), @@ -287,7 +352,7 @@ reset = "session" let tmp = TempDir::new().unwrap(); setup_checklist(tmp.path()); let stdin = claude_stdin("ls -la", "s1"); - let resp = run_app(stdin.as_bytes(), tmp.path()).unwrap(); + let resp = run_app(stdin.as_bytes(), tmp.path(), None).unwrap(); assert!(matches!(resp, polyhook::HookResponse::ApproveResponse(_))); } @@ -296,14 +361,14 @@ reset = "session" let tmp = TempDir::new().unwrap(); setup_checklist(tmp.path()); let stdin = claude_stdin("git push origin main", "s1"); - let resp = run_app(stdin.as_bytes(), tmp.path()).unwrap(); + let resp = run_app(stdin.as_bytes(), tmp.path(), None).unwrap(); assert!(matches!(resp, polyhook::HookResponse::BlockResponse(_))); } #[test] fn run_app_error_on_invalid_input() { let tmp = TempDir::new().unwrap(); - let err = run_app(b"not valid json".as_ref(), tmp.path()); + let err = run_app(b"not valid json".as_ref(), tmp.path(), None); assert!(err.is_err()); assert!(err .unwrap_err() @@ -330,7 +395,7 @@ reset = "session" ) .unwrap(); let stdin = claude_stdin("anything", "s1"); - let err = run_app(stdin.as_bytes(), tmp.path()); + let err = run_app(stdin.as_bytes(), tmp.path(), None); assert!(err.is_err()); assert!(err.unwrap_err().contains("steplock: error:")); } @@ -388,7 +453,7 @@ reset = "session" let tmp = TempDir::new().unwrap(); run_init(tmp.path()).unwrap(); let stdin = claude_stdin("git push origin main", "s1"); - let resp = run_app(stdin.as_bytes(), tmp.path()).unwrap(); + let resp = run_app(stdin.as_bytes(), tmp.path(), None).unwrap(); assert!(matches!(resp, polyhook::HookResponse::BlockResponse(_))); } @@ -444,21 +509,21 @@ reset = "session" #[test] fn validate_returns_true_when_no_checklists_dir() { let tmp = TempDir::new().unwrap(); - assert!(run_validate(tmp.path()).unwrap()); + assert!(run_validate(tmp.path(), None).unwrap()); } #[test] fn validate_returns_true_when_checklists_empty() { let tmp = TempDir::new().unwrap(); fs::create_dir_all(tmp.path().join(".steplock/checklists")).unwrap(); - assert!(run_validate(tmp.path()).unwrap()); + assert!(run_validate(tmp.path(), None).unwrap()); } #[test] fn validate_returns_true_for_valid_checklist() { let tmp = TempDir::new().unwrap(); setup_checklist(tmp.path()); - assert!(run_validate(tmp.path()).unwrap()); + assert!(run_validate(tmp.path(), None).unwrap()); } #[test] @@ -471,7 +536,7 @@ reset = "session" "stateDiagram-v2\n [*] --> s\n s --> [*]\n s: Step\n", ) .unwrap(); - assert!(!run_validate(tmp.path()).unwrap()); + assert!(!run_validate(tmp.path(), None).unwrap()); } #[test] @@ -484,7 +549,7 @@ reset = "session" "on_event = \"tool:before\"\nreset = \"session\"\n", ) .unwrap(); - assert!(!run_validate(tmp.path()).unwrap()); + assert!(!run_validate(tmp.path(), None).unwrap()); } #[test] @@ -498,7 +563,7 @@ reset = "session" "stateDiagram-v2\n [*] --> s\n s --> [*]\n s: Step\n", ) .unwrap(); - assert!(!run_validate(tmp.path()).unwrap()); + assert!(!run_validate(tmp.path(), None).unwrap()); } #[test] @@ -512,7 +577,7 @@ reset = "session" ) .unwrap(); fs::write(cl_dir.join("flow.mmd"), "stateDiagram-v2\n a --> b\n").unwrap(); - assert!(!run_validate(tmp.path()).unwrap()); + assert!(!run_validate(tmp.path(), None).unwrap()); } #[test] @@ -527,6 +592,6 @@ reset = "session" "stateDiagram-v2\n [*] --> s\n s --> [*]\n s: Step\n", ) .unwrap(); - assert!(!run_validate(tmp.path()).unwrap()); + assert!(!run_validate(tmp.path(), None).unwrap()); } } diff --git a/core/src/global_config.rs b/core/src/global_config.rs new file mode 100644 index 0000000..5bda62f --- /dev/null +++ b/core/src/global_config.rs @@ -0,0 +1,120 @@ +//! Location of the global steplock directory shared by every project. +//! +//! The global directory has the same layout as a project `.steplock/`: +//! `checklists//{config.toml,flow.mmd}`, `sessions/` and `audit.log`. +use std::env; +use std::ffi::OsString; +use std::path::PathBuf; + +/// Environment variable that overrides the global steplock directory. +/// Set it to an empty string to turn global checklists off. +pub const GLOBAL_DIR_ENV: &str = "STEPLOCK_GLOBAL_DIR"; + +/// Resolve the global steplock directory from the process environment. +/// +/// Lookup order: +/// 1. `$STEPLOCK_GLOBAL_DIR` — used as-is; an empty value disables global checklists. +/// 2. `$XDG_CONFIG_HOME/steplock` — when `XDG_CONFIG_HOME` is set to an absolute path. +/// 3. `$HOME/.config/steplock`. +/// +/// Returns `None` when global checklists are disabled or no home directory is known. +/// The directory is not required to exist. +#[must_use] +pub fn global_steplock_dir() -> Option { + resolve_global_dir(|key| env::var_os(key)) +} + +/// Resolve the global steplock directory with `var` as the environment lookup. +fn resolve_global_dir(var: impl Fn(&str) -> Option) -> Option { + if let Some(dir) = var(GLOBAL_DIR_ENV) { + return if dir.is_empty() { + None + } else { + Some(PathBuf::from(dir)) + }; + } + if let Some(xdg) = var("XDG_CONFIG_HOME").map(PathBuf::from) { + if xdg.is_absolute() { + return Some(xdg.join("steplock")); + } + } + var("HOME") + .filter(|home| !home.is_empty()) + .map(|home| PathBuf::from(home).join(".config").join("steplock")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn lookup(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let map: HashMap = vars + .iter() + .map(|(k, v)| ((*k).to_owned(), OsString::from(v))) + .collect(); + move |key| map.get(key).cloned() + } + + #[test] + fn env_override_wins() { + let dir = resolve_global_dir(lookup(&[ + (GLOBAL_DIR_ENV, "/custom/steplock"), + ("XDG_CONFIG_HOME", "/xdg"), + ("HOME", "/home/me"), + ])); + assert_eq!( + dir, + Some(PathBuf::from("/custom/steplock")), + "STEPLOCK_GLOBAL_DIR must take precedence" + ); + } + + #[test] + fn empty_env_override_disables_global() { + let dir = resolve_global_dir(lookup(&[(GLOBAL_DIR_ENV, ""), ("HOME", "/home/me")])); + assert_eq!(dir, None, "empty STEPLOCK_GLOBAL_DIR must disable global"); + } + + #[test] + fn uses_xdg_config_home() { + let dir = resolve_global_dir(lookup(&[("XDG_CONFIG_HOME", "/xdg"), ("HOME", "/home/me")])); + assert_eq!( + dir, + Some(PathBuf::from("/xdg/steplock")), + "XDG path expected" + ); + } + + #[test] + fn ignores_relative_xdg_config_home() { + let dir = resolve_global_dir(lookup(&[ + ("XDG_CONFIG_HOME", "relative"), + ("HOME", "/home/me"), + ])); + assert_eq!( + dir, + Some(PathBuf::from("/home/me/.config/steplock")), + "relative XDG_CONFIG_HOME must fall back to HOME" + ); + } + + #[test] + fn falls_back_to_home() { + let dir = resolve_global_dir(lookup(&[("HOME", "/home/me")])); + assert_eq!( + dir, + Some(PathBuf::from("/home/me/.config/steplock")), + "HOME fallback expected" + ); + } + + #[test] + fn none_without_home() { + assert_eq!( + resolve_global_dir(lookup(&[])), + None, + "no env means no global dir" + ); + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs index acf7c58..be196af 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -16,6 +16,8 @@ pub(crate) mod config; pub mod error; /// Mermaid `stateDiagram-v2` parser. pub mod flow; +/// Location of the global steplock directory shared by every project. +pub mod global_config; /// Gate runner — entry point for polyhook integration. pub mod run; /// Ack and preview script generation. @@ -26,6 +28,7 @@ pub mod state; pub mod validate; pub use error::{Result, SteplockError}; -pub use run::run; +pub use global_config::global_steplock_dir; +pub use run::{run, run_with_global}; pub use state::{HookEvent, HookResponse, SessionState}; pub use validate::validate_checklists; diff --git a/core/src/run.rs b/core/src/run.rs index 5e31f8d..8983ddd 100644 --- a/core/src/run.rs +++ b/core/src/run.rs @@ -11,102 +11,161 @@ use crate::flow::{parse_mmd, FlowGraph}; use crate::scripts; use crate::state::{init_state, load_state, save_state, HookEvent, HookResponse, SessionState}; -/// Run the full steplock gate logic. +/// Run the full steplock gate logic against the project checklists only. /// /// `repo_root` — the directory that contains `.steplock/`. /// Returns `HookResponse::Approve` if no checklist blocks, or /// `HookResponse::Block { message }` with the gate message. /// +/// Equivalent to [`run_with_global`] with no global steplock directory. +/// /// # Errors /// /// Returns `Err` on I/O failures (reading checklist files, writing state) or on invalid /// checklist configuration (bad TOML, invalid Mermaid, invalid CEL expression). +pub fn run(event: &HookEvent, repo_root: &Path) -> Result { + run_with_global(event, repo_root, None) +} + +/// Run the gate logic against the project checklists, then the global checklists. /// -/// # Panics +/// `repo_root` — the directory that contains the project `.steplock/`. +/// `global_dir` — a steplock directory shared by every project (see +/// [`crate::global_config::global_steplock_dir`]). It has the same layout as `.steplock/`: +/// `checklists/`, `sessions/` and `audit.log`. /// -/// Panics if `parse_mmd` returns a graph with no initial state, which it guarantees cannot -/// happen. -pub fn run(event: &HookEvent, repo_root: &Path) -> Result { - let steplock_dir = repo_root.join(".steplock"); +/// Project checklists are evaluated first. A global checklist is skipped when the project +/// has a checklist with the same name, so a project can override or disable it. Session +/// state for a global checklist lives in `global_dir`, not in the project. +/// +/// # Errors +/// +/// Returns `Err` on I/O failures (reading checklist files, writing state) or on invalid +/// checklist configuration (bad TOML, invalid Mermaid, invalid CEL expression). +pub fn run_with_global( + event: &HookEvent, + repo_root: &Path, + global_dir: Option<&Path>, +) -> Result { + let project_dir = repo_root.join(".steplock"); + let global_dir = global_dir.filter(|g| !is_same_dir(g, &project_dir)); if event.event == "session:stop" { - cleanup_session(&steplock_dir, &event.session_id)?; + cleanup_session(&project_dir, &event.session_id)?; + if let Some(global) = global_dir { + cleanup_session(global, &event.session_id)?; + } return Ok(HookResponse::Approve); } - let checklists_dir = steplock_dir.join("checklists"); + let project_checklists = checklist_dirs(&project_dir)?; + for checklist_dir in &project_checklists { + if let Some(resp) = evaluate_checklist(event, &project_dir, checklist_dir)? { + return Ok(resp); + } + } - if !checklists_dir.exists() { - return Ok(HookResponse::Approve); + if let Some(global) = global_dir { + let project_names: Vec<_> = project_checklists + .iter() + .filter_map(|p| p.file_name()) + .collect(); + for checklist_dir in checklist_dirs(global)? { + let shadowed = checklist_dir + .file_name() + .is_some_and(|n| project_names.contains(&n)); + if shadowed { + continue; + } + if let Some(resp) = evaluate_checklist(event, global, &checklist_dir)? { + return Ok(resp); + } + } + } + + Ok(HookResponse::Approve) +} + +/// `true` when both paths resolve to the same existing directory. +fn is_same_dir(a: &Path, b: &Path) -> bool { + match (fs::canonicalize(a), fs::canonicalize(b)) { + (Ok(a), Ok(b)) => a == b, + _ => false, } +} +/// Checklist directories under `/checklists/`, sorted by name. +/// Returns an empty list when the directory does not exist. +fn checklist_dirs(steplock_dir: &Path) -> Result> { + let checklists_dir = steplock_dir.join("checklists"); + if !checklists_dir.exists() { + return Ok(vec![]); + } let mut entries: Vec = fs::read_dir(&checklists_dir)? .filter_map(|e| e.ok().map(|e| e.path())) .filter(|p| p.is_dir()) .collect(); entries.sort(); // deterministic declaration order + Ok(entries) +} - for checklist_dir in entries { - let checklist_name = checklist_dir - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("") - .to_owned(); +/// Evaluate one checklist. Returns `Some(Block)` when it blocks the event, `None` otherwise. +fn evaluate_checklist( + event: &HookEvent, + steplock_dir: &Path, + checklist_dir: &Path, +) -> Result> { + let checklist_name = checklist_dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_owned(); - let config_path = checklist_dir.join("config.toml"); - let flow_path = checklist_dir.join("flow.mmd"); + let config_path = checklist_dir.join("config.toml"); + let flow_path = checklist_dir.join("flow.mmd"); - if !config_path.exists() || !flow_path.exists() { - continue; - } + if !config_path.exists() || !flow_path.exists() { + return Ok(None); + } - let config_str = fs::read_to_string(&config_path)?; - let config = parse_config(config_path.to_str().unwrap_or("config.toml"), &config_str)?; + let config_str = fs::read_to_string(&config_path)?; + let config = parse_config(config_path.to_str().unwrap_or("config.toml"), &config_str)?; - if config.on_event != event.event { - continue; - } - if !config.on_tool.is_empty() && config.on_tool != event.tool { - continue; - } - - if !cel_eval::matches_event(event, &config.match_input)? { - continue; - } + if config.on_event != event.event { + return Ok(None); + } + if !config.on_tool.is_empty() && config.on_tool != event.tool { + return Ok(None); + } - let flow_str = fs::read_to_string(&flow_path)?; - let flow = parse_mmd(flow_path.to_str().unwrap_or("flow.mmd"), &flow_str)?; - - let initial_state = flow.initial.first().ok_or_else(|| SteplockError::Mermaid { - path: flow_path.to_str().unwrap_or("flow.mmd").to_owned(), - message: "no initial state found".to_owned(), - })?; - - match config.reset { - Reset::Always => { - return Ok(block_reset_always( - &steplock_dir, - &checklist_name, - initial_state, - &flow, - )); - } - Reset::Session => { - if let Some(resp) = block_reset_session( - event, - &steplock_dir, - &checklist_name, - initial_state, - &flow, - config.allow_preview_request, - )? { - return Ok(resp); - } - } - } + if !cel_eval::matches_event(event, &config.match_input)? { + return Ok(None); } - Ok(HookResponse::Approve) + let flow_str = fs::read_to_string(&flow_path)?; + let flow = parse_mmd(flow_path.to_str().unwrap_or("flow.mmd"), &flow_str)?; + + let initial_state = flow.initial.first().ok_or_else(|| SteplockError::Mermaid { + path: flow_path.to_str().unwrap_or("flow.mmd").to_owned(), + message: "no initial state found".to_owned(), + })?; + + match config.reset { + Reset::Always => Ok(Some(block_reset_always( + steplock_dir, + &checklist_name, + initial_state, + &flow, + ))), + Reset::Session => block_reset_session( + event, + steplock_dir, + &checklist_name, + initial_state, + &flow, + config.allow_preview_request, + ), + } } fn block_reset_always( diff --git a/core/tests/cli.rs b/core/tests/cli.rs index 14358dc..f2371da 100644 --- a/core/tests/cli.rs +++ b/core/tests/cli.rs @@ -37,8 +37,14 @@ fn checklist(root: &Path, on_event: &str, on_tool: &str, match_input: Option<&st } fn run_steplock(root: &Path, stdin: &str) -> (i32, String, String) { + run_steplock_with_global(root, stdin, "") +} + +/// Run the hook with `STEPLOCK_GLOBAL_DIR` set to `global` (`""` disables global checklists). +fn run_steplock_with_global(root: &Path, stdin: &str, global: &str) -> (i32, String, String) { let mut child = Command::new(STEPLOCK) .current_dir(root) + .env("STEPLOCK_GLOBAL_DIR", global) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -204,6 +210,7 @@ fn hook_finds_steplock_dir_in_parent() { let mut child = Command::new(STEPLOCK) .current_dir(&subdir) + .env("STEPLOCK_GLOBAL_DIR", "") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -222,3 +229,141 @@ fn hook_finds_steplock_dir_in_parent() { "should block even from subdirectory; got: {stdout}" ); } + +// ── Global checklists ────────────────────────────────────────────────────── + +/// Write a `git push` checklist named `name` into `steplock_dir/checklists/`. +fn global_checklist(steplock_dir: &Path, name: &str, question: &str) { + let dir = steplock_dir.join("checklists").join(name); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join("config.toml"), + "on_event = \"tool:before\"\non_tool = \"bash\"\nmatch_input = \"input.command.contains('git push')\"\n", + ) + .unwrap(); + fs::write( + dir.join("flow.mmd"), + format!("stateDiagram-v2\n [*] --> q\n q --> [*]\n q: {question}\n"), + ) + .unwrap(); +} + +#[test] +fn hook_blocks_with_global_checklist_in_project_without_steplock() { + let project = TempDir::new().unwrap(); + let global = TempDir::new().unwrap(); + global_checklist(global.path(), "push-gate", "Global push question?"); + + let stdin = hook_event("bash", "git push origin main", "sess-g"); + let (code, stdout, _stderr) = + run_steplock_with_global(project.path(), &stdin, global.path().to_str().unwrap()); + assert_eq!(code, 0, "block response exits 0"); + assert!( + stdout.contains("Global push question?"), + "expected global checklist block, got: {stdout}" + ); + assert!( + global + .path() + .join("sessions/sess-g/push-gate/state.json") + .exists(), + "global session state must live in the global dir" + ); + assert!( + !project.path().join(".steplock").exists(), + "project dir must stay untouched" + ); +} + +#[test] +fn hook_ignores_global_checklist_when_disabled() { + let project = TempDir::new().unwrap(); + let stdin = hook_event("bash", "git push origin main", "sess-g"); + let (code, stdout, _stderr) = run_steplock_with_global(project.path(), &stdin, ""); + assert_eq!(code, 0, "approve exits 0"); + assert!( + stdout.trim() == "{}" || stdout.is_empty(), + "expected approve, got: {stdout}" + ); +} + +#[test] +fn init_global_scaffolds_global_dir() { + let global = TempDir::new().unwrap(); + let target = global.path().join("steplock"); + let output = Command::new(STEPLOCK) + .args(["init", "--global"]) + .env("STEPLOCK_GLOBAL_DIR", &target) + .output() + .expect("failed to run steplock init --global"); + assert!(output.status.success(), "init --global should succeed"); + assert!( + target.join("checklists/example-gate/config.toml").exists(), + "sample checklist expected in global dir" + ); + assert!( + !target.join(".gitignore").exists(), + "global dir is not a repo; no .gitignore" + ); +} + +#[test] +fn init_global_fails_when_disabled() { + let output = Command::new(STEPLOCK) + .args(["init", "--global"]) + .env("STEPLOCK_GLOBAL_DIR", "") + .output() + .expect("failed to run steplock init --global"); + assert_eq!( + output.status.code(), + Some(1), + "disabled global dir must fail" + ); +} + +#[test] +fn validate_reports_invalid_global_checklist() { + let project = TempDir::new().unwrap(); + let global = TempDir::new().unwrap(); + let bad = global.path().join("checklists/bad"); + fs::create_dir_all(&bad).unwrap(); + fs::write(bad.join("config.toml"), "not valid toml").unwrap(); + fs::write( + bad.join("flow.mmd"), + "stateDiagram-v2\n [*] --> s\n s --> [*]\n s: Step\n", + ) + .unwrap(); + let output = Command::new(STEPLOCK) + .arg("validate") + .current_dir(project.path()) + .env("STEPLOCK_GLOBAL_DIR", global.path()) + .output() + .expect("failed to run steplock validate"); + assert_eq!( + output.status.code(), + Some(1), + "invalid global checklist fails" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("[global:bad/config.toml]"), + "error label must name the global checklist, got: {stderr}" + ); +} + +#[test] +fn clean_global_removes_global_sessions() { + let global = TempDir::new().unwrap(); + let session = global.path().join("sessions/s1/gate"); + fs::create_dir_all(&session).unwrap(); + let output = Command::new(STEPLOCK) + .args(["clean", "--global"]) + .env("STEPLOCK_GLOBAL_DIR", global.path()) + .output() + .expect("failed to run steplock clean --global"); + assert!(output.status.success(), "clean --global should succeed"); + assert!( + !global.path().join("sessions/s1").exists(), + "global session dir must be removed" + ); +} diff --git a/core/tests/gate_integration.rs b/core/tests/gate_integration.rs index a87853c..bb90de0 100644 --- a/core/tests/gate_integration.rs +++ b/core/tests/gate_integration.rs @@ -245,3 +245,153 @@ fn state_json_readable_as_session_state() { assert!(state.visited.is_empty()); assert!(!state.is_complete()); } + +// ── Global checklists ────────────────────────────────────────────────────── + +/// Write a one-step checklist into a bare steplock dir (`/checklists//`). +fn write_global_checklist(steplock_dir: &Path, name: &str, label: &str) { + let cl_dir = steplock_dir.join("checklists").join(name); + fs::create_dir_all(&cl_dir).unwrap(); + fs::write( + cl_dir.join("config.toml"), + "on_event = \"tool:before\"\non_tool = \"bash\"\nreset = \"session\"\n", + ) + .unwrap(); + fs::write( + cl_dir.join("flow.mmd"), + format!("stateDiagram-v2\n [*] --> g\n g --> [*]\n g: {label}\n"), + ) + .unwrap(); +} + +fn block_message(resp: HookResponse) -> String { + match resp { + HookResponse::Block { message } => message, + HookResponse::Approve => panic!("expected block"), + _ => panic!("unexpected variant"), + } +} + +#[test] +fn global_checklist_blocks_when_project_has_none() { + let project = tempfile::TempDir::new().unwrap(); + let global = tempfile::TempDir::new().unwrap(); + write_global_checklist(global.path(), "gate", "Global step"); + + let resp = + steplock::run_with_global(&push_event("s1"), project.path(), Some(global.path())).unwrap(); + assert!( + block_message(resp).contains("Global step"), + "global checklist must block" + ); + assert!( + global.path().join("sessions/s1/gate/state.json").exists(), + "state must be stored in the global dir" + ); +} + +#[test] +fn project_checklists_run_before_global() { + let project = tempfile::TempDir::new().unwrap(); + let global = tempfile::TempDir::new().unwrap(); + write_checklist(project.path(), "project-gate", &[("p", "Project step")]); + write_global_checklist(global.path(), "global-gate", "Global step"); + let event = push_event("s1"); + + let first = steplock::run_with_global(&event, project.path(), Some(global.path())).unwrap(); + assert!( + block_message(first).contains("Project step"), + "project checklist must block first" + ); + + ack(project.path(), "project-gate", "s1", "[*]"); + let second = steplock::run_with_global(&event, project.path(), Some(global.path())).unwrap(); + assert!( + block_message(second).contains("Global step"), + "global checklist must block after the project one completes" + ); +} + +#[test] +fn project_checklist_shadows_global_with_same_name() { + let project = tempfile::TempDir::new().unwrap(); + let global = tempfile::TempDir::new().unwrap(); + write_checklist(project.path(), "gate", &[("p", "Project step")]); + write_global_checklist(global.path(), "gate", "Global step"); + let event = push_event("s1"); + + ack_after_block(project.path(), global.path(), &event); + let resp = steplock::run_with_global(&event, project.path(), Some(global.path())).unwrap(); + assert!( + matches!(resp, HookResponse::Approve), + "shadowed global checklist must not run" + ); +} + +/// Block once on the project `gate` checklist, then ack it to completion. +fn ack_after_block(project: &Path, global: &Path, event: &HookEvent) { + let resp = steplock::run_with_global(event, project, Some(global)).unwrap(); + assert!( + block_message(resp).contains("Project step"), + "project checklist must win over same-name global" + ); + ack(project, "gate", "s1", "[*]"); +} + +#[test] +fn empty_project_dir_disables_same_name_global_checklist() { + let project = tempfile::TempDir::new().unwrap(); + let global = tempfile::TempDir::new().unwrap(); + fs::create_dir_all(project.path().join(".steplock/checklists/gate")).unwrap(); + write_global_checklist(global.path(), "gate", "Global step"); + + let resp = + steplock::run_with_global(&push_event("s1"), project.path(), Some(global.path())).unwrap(); + assert!( + matches!(resp, HookResponse::Approve), + "empty same-name project dir must disable the global checklist" + ); +} + +#[test] +fn global_dir_equal_to_project_dir_is_evaluated_once() { + let project = tempfile::TempDir::new().unwrap(); + write_checklist(project.path(), "gate", &[("p", "Project step")]); + let steplock_dir = project.path().join(".steplock"); + let event = push_event("s1"); + + let first = steplock::run_with_global(&event, project.path(), Some(&steplock_dir)).unwrap(); + assert!(block_message(first).contains("Project step"), "blocks once"); + ack(project.path(), "gate", "s1", "[*]"); + let second = steplock::run_with_global(&event, project.path(), Some(&steplock_dir)).unwrap(); + assert!( + matches!(second, HookResponse::Approve), + "same dir must not be evaluated twice" + ); +} + +#[test] +fn session_stop_cleans_global_sessions() { + let project = tempfile::TempDir::new().unwrap(); + let global = tempfile::TempDir::new().unwrap(); + write_global_checklist(global.path(), "gate", "Global step"); + steplock::run_with_global(&push_event("s1"), project.path(), Some(global.path())).unwrap(); + assert!( + global.path().join("sessions/s1").exists(), + "session created" + ); + + let stop = HookEvent::new( + "session:stop".to_owned(), + String::new(), + HashMap::new(), + HashMap::new(), + "s1".to_owned(), + "claude-code".to_owned(), + ); + steplock::run_with_global(&stop, project.path(), Some(global.path())).unwrap(); + assert!( + !global.path().join("sessions/s1").exists(), + "session:stop must clean the global session dir" + ); +} From 77be1c43e3d35aaf8a708b01b75c5bb46f5aebc2 Mon Sep 17 00:00:00 2001 From: Ofek Gabay Date: Thu, 24 Sep 2026 10:09:45 +0300 Subject: [PATCH 2/4] fix: resolve global dir portably on Windows Tests used Unix-only absolute paths, and Windows usually has no HOME. Fall back to USERPROFILE and build test paths per platform. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- core/src/global_config.rs | 70 +++++++++++++++++++++++++++------------ 2 files changed, 49 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 5a15ecf..e9b50ed 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ steplock finds the global directory in this order: 1. `$STEPLOCK_GLOBAL_DIR`. Set it to an empty string to turn global checklists off. 2. `$XDG_CONFIG_HOME/steplock`, when `XDG_CONFIG_HOME` is an absolute path. -3. `~/.config/steplock`. +3. `~/.config/steplock` (on Windows without `HOME`, `%USERPROFILE%\.config\steplock`). Rules: diff --git a/core/src/global_config.rs b/core/src/global_config.rs index 5bda62f..a32663b 100644 --- a/core/src/global_config.rs +++ b/core/src/global_config.rs @@ -15,7 +15,7 @@ pub const GLOBAL_DIR_ENV: &str = "STEPLOCK_GLOBAL_DIR"; /// Lookup order: /// 1. `$STEPLOCK_GLOBAL_DIR` — used as-is; an empty value disables global checklists. /// 2. `$XDG_CONFIG_HOME/steplock` — when `XDG_CONFIG_HOME` is set to an absolute path. -/// 3. `$HOME/.config/steplock`. +/// 3. `$HOME/.config/steplock`, or `%USERPROFILE%\.config\steplock` when `HOME` is unset. /// /// Returns `None` when global checklists are disabled or no home directory is known. /// The directory is not required to exist. @@ -38,8 +38,10 @@ fn resolve_global_dir(var: impl Fn(&str) -> Option) -> Option return Some(xdg.join("steplock")); } } - var("HOME") - .filter(|home| !home.is_empty()) + ["HOME", "USERPROFILE"] + .into_iter() + .filter_map(&var) + .find(|home| !home.is_empty()) .map(|home| PathBuf::from(home).join(".config").join("steplock")) } @@ -48,64 +50,88 @@ mod tests { use super::*; use std::collections::HashMap; - fn lookup(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option { + /// An absolute path on the current platform (`/name` or `C:\name`). + fn abs(name: &str) -> PathBuf { + let root = if cfg!(windows) { "C:\\" } else { "/" }; + PathBuf::from(root).join(name) + } + + fn lookup(vars: &[(&str, OsString)]) -> impl Fn(&str) -> Option { let map: HashMap = vars .iter() - .map(|(k, v)| ((*k).to_owned(), OsString::from(v))) + .map(|(k, v)| ((*k).to_owned(), v.clone())) .collect(); move |key| map.get(key).cloned() } + fn home_config(home: &str) -> PathBuf { + abs(home).join(".config").join("steplock") + } + #[test] fn env_override_wins() { let dir = resolve_global_dir(lookup(&[ - (GLOBAL_DIR_ENV, "/custom/steplock"), - ("XDG_CONFIG_HOME", "/xdg"), - ("HOME", "/home/me"), + (GLOBAL_DIR_ENV, abs("custom").into()), + ("XDG_CONFIG_HOME", abs("xdg").into()), + ("HOME", abs("home").into()), ])); assert_eq!( dir, - Some(PathBuf::from("/custom/steplock")), + Some(abs("custom")), "STEPLOCK_GLOBAL_DIR must take precedence" ); } #[test] fn empty_env_override_disables_global() { - let dir = resolve_global_dir(lookup(&[(GLOBAL_DIR_ENV, ""), ("HOME", "/home/me")])); + let dir = resolve_global_dir(lookup(&[ + (GLOBAL_DIR_ENV, OsString::new()), + ("HOME", abs("home").into()), + ])); assert_eq!(dir, None, "empty STEPLOCK_GLOBAL_DIR must disable global"); } #[test] fn uses_xdg_config_home() { - let dir = resolve_global_dir(lookup(&[("XDG_CONFIG_HOME", "/xdg"), ("HOME", "/home/me")])); - assert_eq!( - dir, - Some(PathBuf::from("/xdg/steplock")), - "XDG path expected" - ); + let dir = resolve_global_dir(lookup(&[ + ("XDG_CONFIG_HOME", abs("xdg").into()), + ("HOME", abs("home").into()), + ])); + assert_eq!(dir, Some(abs("xdg").join("steplock")), "XDG path expected"); } #[test] fn ignores_relative_xdg_config_home() { let dir = resolve_global_dir(lookup(&[ - ("XDG_CONFIG_HOME", "relative"), - ("HOME", "/home/me"), + ("XDG_CONFIG_HOME", "relative".into()), + ("HOME", abs("home").into()), ])); assert_eq!( dir, - Some(PathBuf::from("/home/me/.config/steplock")), + Some(home_config("home")), "relative XDG_CONFIG_HOME must fall back to HOME" ); } #[test] fn falls_back_to_home() { - let dir = resolve_global_dir(lookup(&[("HOME", "/home/me")])); + let dir = resolve_global_dir(lookup(&[ + ("HOME", abs("home").into()), + ("USERPROFILE", abs("profile").into()), + ])); + assert_eq!(dir, Some(home_config("home")), "HOME wins over USERPROFILE"); + } + + #[test] + fn falls_back_to_userprofile_without_home() { + let dir = resolve_global_dir(lookup(&[ + ("HOME", OsString::new()), + ("USERPROFILE", abs("profile").into()), + ])); assert_eq!( dir, - Some(PathBuf::from("/home/me/.config/steplock")), - "HOME fallback expected" + Some(home_config("profile")), + "USERPROFILE fallback expected" ); } From a5170707c55470951c853be3982dcd3bb13602fe Mon Sep 17 00:00:00 2001 From: Ofek Gabay Date: Thu, 24 Sep 2026 10:16:33 +0300 Subject: [PATCH 3/4] refactor: address review on global checklists - Move checklist discovery into catalog.rs. - Move per-checklist gate evaluation (and the block/message helpers it owns) into gate.rs; run.rs now only orchestrates project and global directories. - Use the same-file crate instead of hand-rolled canonicalize comparison. - Share one spawn helper across CLI tests instead of duplicating it. Co-Authored-By: Claude Opus 5.5 (1M context) --- core/Cargo.lock | 19 +++ core/Cargo.toml | 1 + core/src/catalog.rs | 20 ++++ core/src/gate.rs | 249 +++++++++++++++++++++++++++++++++++++++ core/src/lib.rs | 4 + core/src/run.rs | 278 ++------------------------------------------ core/tests/cli.rs | 62 ++++------ 7 files changed, 322 insertions(+), 311 deletions(-) create mode 100644 core/src/catalog.rs create mode 100644 core/src/gate.rs diff --git a/core/Cargo.lock b/core/Cargo.lock index 5212033..93dfdf0 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -696,6 +696,15 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schemars" version = "0.8.22" @@ -837,6 +846,7 @@ dependencies = [ "chrono", "polyhook", "proptest", + "same-file", "serde", "serde_json", "tempfile", @@ -1175,6 +1185,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/core/Cargo.toml b/core/Cargo.toml index 0746a4a..ca0384f 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -88,6 +88,7 @@ manual_let_else = "deny" cel-interpreter = "0.10" chrono = { version = "0.4", features = ["serde"] } polyhook = "0.1.5" +same-file = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" diff --git a/core/src/catalog.rs b/core/src/catalog.rs new file mode 100644 index 0000000..812ad6b --- /dev/null +++ b/core/src/catalog.rs @@ -0,0 +1,20 @@ +//! Checklist catalog: discovers the checklists defined in a steplock directory. +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::error::Result; + +/// Checklist directories under `/checklists/`, sorted by name. +/// Returns an empty list when the directory does not exist. +pub(crate) fn checklist_dirs(steplock_dir: &Path) -> Result> { + let checklists_dir = steplock_dir.join("checklists"); + if !checklists_dir.exists() { + return Ok(vec![]); + } + let mut entries: Vec = fs::read_dir(&checklists_dir)? + .filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.is_dir()) + .collect(); + entries.sort(); // deterministic declaration order + Ok(entries) +} diff --git a/core/src/gate.rs b/core/src/gate.rs new file mode 100644 index 0000000..93f5a73 --- /dev/null +++ b/core/src/gate.rs @@ -0,0 +1,249 @@ +//! Checklist gate: decides whether one checklist blocks a hook event. +use std::fmt::Write as _; +use std::fs; +use std::path::Path; + +use crate::audit; +use crate::cel_eval; +use crate::config::{parse_config, Reset}; +use crate::error::{Result, SteplockError}; +use crate::flow::{parse_mmd, FlowGraph}; +use crate::scripts; +use crate::state::{init_state, load_state, save_state, HookEvent, HookResponse, SessionState}; + +/// Evaluate one checklist. Returns `Some(Block)` when it blocks the event, `None` otherwise. +pub(crate) fn evaluate_checklist( + event: &HookEvent, + steplock_dir: &Path, + checklist_dir: &Path, +) -> Result> { + let checklist_name = checklist_dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_owned(); + + let config_path = checklist_dir.join("config.toml"); + let flow_path = checklist_dir.join("flow.mmd"); + + if !config_path.exists() || !flow_path.exists() { + return Ok(None); + } + + let config_str = fs::read_to_string(&config_path)?; + let config = parse_config(config_path.to_str().unwrap_or("config.toml"), &config_str)?; + + if config.on_event != event.event { + return Ok(None); + } + if !config.on_tool.is_empty() && config.on_tool != event.tool { + return Ok(None); + } + + if !cel_eval::matches_event(event, &config.match_input)? { + return Ok(None); + } + + let flow_str = fs::read_to_string(&flow_path)?; + let flow = parse_mmd(flow_path.to_str().unwrap_or("flow.mmd"), &flow_str)?; + + let initial_state = flow.initial.first().ok_or_else(|| SteplockError::Mermaid { + path: flow_path.to_str().unwrap_or("flow.mmd").to_owned(), + message: "no initial state found".to_owned(), + })?; + + match config.reset { + Reset::Always => Ok(Some(block_reset_always( + steplock_dir, + &checklist_name, + initial_state, + &flow, + ))), + Reset::Session => block_reset_session( + event, + steplock_dir, + &checklist_name, + initial_state, + &flow, + config.allow_preview_request, + ), + } +} + +fn block_reset_always( + steplock_dir: &Path, + checklist_name: &str, + initial_state: &str, + flow: &FlowGraph, +) -> HookResponse { + let transitions: Vec = flow + .transitions + .get(initial_state) + .cloned() + .unwrap_or_default(); + let next_state = transitions + .first() + .cloned() + .filter(|_| transitions.len() == 1); + let state = SessionState { + checklist: checklist_name.to_owned(), + current_state: initial_state.to_owned(), + next_state, + transitions, + visited: vec![], + }; + audit::append( + steplock_dir, + "block", + checklist_name, + initial_state, + "always", + ); + let message = build_block_message(&state, flow, None); + eprintln!("steplock: block [{checklist_name}] state={initial_state}"); + HookResponse::Block { message } +} + +fn block_reset_session( + event: &HookEvent, + steplock_dir: &Path, + checklist_name: &str, + initial_state: &str, + flow: &FlowGraph, + allow_preview: bool, +) -> Result> { + let scope_key = get_scope_key(event, steplock_dir)?; + let session_dir = steplock_dir + .join("sessions") + .join(&scope_key) + .join(checklist_name); + fs::create_dir_all(&session_dir)?; + + let state_path = session_dir.join("state.json"); + let mut state = if state_path.exists() { + load_state(&state_path)? + } else { + init_state(checklist_name, initial_state) + }; + + // Checklist complete — approve this attempt and reset state so the + // next invocation starts the checklist fresh. + if state.is_complete() { + audit::append(steplock_dir, "complete", checklist_name, "[*]", &scope_key); + save_state(&state_path, &init_state(checklist_name, initial_state))?; + return Ok(None); + } + + // Raw transitions including [*] — stored in state.json for ack.sh validation. + let raw_transitions: Vec = flow + .transitions + .get(&state.current_state) + .cloned() + .unwrap_or_default(); + + if raw_transitions.is_empty() { + // State unknown in flow — skip silently (flow changed mid-session). + return Ok(None); + } + + // next_state: auto-advance when only one transition (may be "[*]"). + state.next_state = raw_transitions + .first() + .cloned() + .filter(|_| raw_transitions.len() == 1); + state.transitions = raw_transitions; + + save_state(&state_path, &state)?; + scripts::ensure_ack_sh(&session_dir)?; + if allow_preview { + scripts::ensure_preview_sh(&session_dir, checklist_name, flow)?; + } + + audit::append( + steplock_dir, + "block", + checklist_name, + &state.current_state, + &scope_key, + ); + eprintln!( + "steplock: block [{}] state={} session={}", + checklist_name, state.current_state, scope_key + ); + + let message = build_block_message(&state, flow, Some(&session_dir)); + Ok(Some(HookResponse::Block { message })) +} + +fn get_scope_key(event: &HookEvent, steplock_dir: &Path) -> Result { + if !event.session_id.is_empty() { + return Ok(event.session_id.clone()); + } + let fallback_path = steplock_dir.join("sessions").join("fallback-id"); + if fallback_path.exists() { + let id = fs::read_to_string(&fallback_path)?; + return Ok(id.trim().to_owned()); + } + let id = uuid::Uuid::new_v4().to_string(); + fs::create_dir_all(steplock_dir.join("sessions"))?; + fs::write(&fallback_path, &id)?; + Ok(id) +} + +/// `session_dir` is `None` for `reset=always` checklists (no persistent ack.sh). +fn build_block_message( + state: &SessionState, + flow: &FlowGraph, + session_dir: Option<&Path>, +) -> String { + let label = flow + .labels + .get(&state.current_state) + .map_or(state.current_state.as_str(), String::as_str); + + let checklist = &state.checklist; + let step = state.visited.len() + 1; + let total = flow.order.len(); + let mut msg = format!("[{checklist}: {step}/{total}] {label}"); + msg.push_str("\n\n"); + + let visible: Vec<&String> = state + .transitions + .iter() + .filter(|s| s.as_str() != "[*]") + .collect(); + + if let Some(dir) = session_dir { + let ack = dir.join("ack.sh"); + let ack_path = ack.display(); + if visible.len() <= 1 { + let _ = write!( + msg, + "When finished, run: sh {ack_path}\nThen retry your original command." + ); + } else { + msg.push_str("When finished, run one of:\n"); + for next in &visible { + let next_label = flow.labels.get(*next).map_or(next.as_str(), String::as_str); + let _ = writeln!(msg, " sh {ack_path} {next} — {next_label}"); + } + msg.push_str("Then retry your original command."); + } + + if state.visited.is_empty() { + let preview = dir.join("preview.sh"); + if preview.exists() { + let _ = write!( + msg, + "\n(Tip: run sh {} to see all items first.)", + preview.display() + ); + } + } + } else { + // reset=always: no persistent ack.sh — agent confirms in conversation then retries. + msg.push_str("When done, retry your original command."); + } + + msg +} diff --git a/core/src/lib.rs b/core/src/lib.rs index be196af..7921e3f 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -8,6 +8,8 @@ /// Audit log utilities. pub(crate) mod audit; +/// Checklist catalog: discovers checklist directories. +pub(crate) mod catalog; /// CEL expression evaluator for `match_input` conditions. pub(crate) mod cel_eval; /// Checklist configuration types and TOML parser. @@ -16,6 +18,8 @@ pub(crate) mod config; pub mod error; /// Mermaid `stateDiagram-v2` parser. pub mod flow; +/// Checklist gate: evaluates one checklist against a hook event. +pub(crate) mod gate; /// Location of the global steplock directory shared by every project. pub mod global_config; /// Gate runner — entry point for polyhook integration. diff --git a/core/src/run.rs b/core/src/run.rs index 8983ddd..5c7382d 100644 --- a/core/src/run.rs +++ b/core/src/run.rs @@ -1,15 +1,11 @@ //! Core gate logic: evaluates checklists against incoming hook events. -use std::fmt::Write as _; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::Path; -use crate::audit; -use crate::cel_eval; -use crate::config::{parse_config, Reset}; -use crate::error::{Result, SteplockError}; -use crate::flow::{parse_mmd, FlowGraph}; -use crate::scripts; -use crate::state::{init_state, load_state, save_state, HookEvent, HookResponse, SessionState}; +use crate::catalog::checklist_dirs; +use crate::error::Result; +use crate::gate::evaluate_checklist; +use crate::state::{HookEvent, HookResponse}; /// Run the full steplock gate logic against the project checklists only. /// @@ -48,7 +44,8 @@ pub fn run_with_global( global_dir: Option<&Path>, ) -> Result { let project_dir = repo_root.join(".steplock"); - let global_dir = global_dir.filter(|g| !is_same_dir(g, &project_dir)); + let global_dir = + global_dir.filter(|g| !same_file::is_same_file(g, &project_dir).unwrap_or(false)); if event.event == "session:stop" { cleanup_session(&project_dir, &event.session_id)?; @@ -86,193 +83,6 @@ pub fn run_with_global( Ok(HookResponse::Approve) } -/// `true` when both paths resolve to the same existing directory. -fn is_same_dir(a: &Path, b: &Path) -> bool { - match (fs::canonicalize(a), fs::canonicalize(b)) { - (Ok(a), Ok(b)) => a == b, - _ => false, - } -} - -/// Checklist directories under `/checklists/`, sorted by name. -/// Returns an empty list when the directory does not exist. -fn checklist_dirs(steplock_dir: &Path) -> Result> { - let checklists_dir = steplock_dir.join("checklists"); - if !checklists_dir.exists() { - return Ok(vec![]); - } - let mut entries: Vec = fs::read_dir(&checklists_dir)? - .filter_map(|e| e.ok().map(|e| e.path())) - .filter(|p| p.is_dir()) - .collect(); - entries.sort(); // deterministic declaration order - Ok(entries) -} - -/// Evaluate one checklist. Returns `Some(Block)` when it blocks the event, `None` otherwise. -fn evaluate_checklist( - event: &HookEvent, - steplock_dir: &Path, - checklist_dir: &Path, -) -> Result> { - let checklist_name = checklist_dir - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("") - .to_owned(); - - let config_path = checklist_dir.join("config.toml"); - let flow_path = checklist_dir.join("flow.mmd"); - - if !config_path.exists() || !flow_path.exists() { - return Ok(None); - } - - let config_str = fs::read_to_string(&config_path)?; - let config = parse_config(config_path.to_str().unwrap_or("config.toml"), &config_str)?; - - if config.on_event != event.event { - return Ok(None); - } - if !config.on_tool.is_empty() && config.on_tool != event.tool { - return Ok(None); - } - - if !cel_eval::matches_event(event, &config.match_input)? { - return Ok(None); - } - - let flow_str = fs::read_to_string(&flow_path)?; - let flow = parse_mmd(flow_path.to_str().unwrap_or("flow.mmd"), &flow_str)?; - - let initial_state = flow.initial.first().ok_or_else(|| SteplockError::Mermaid { - path: flow_path.to_str().unwrap_or("flow.mmd").to_owned(), - message: "no initial state found".to_owned(), - })?; - - match config.reset { - Reset::Always => Ok(Some(block_reset_always( - steplock_dir, - &checklist_name, - initial_state, - &flow, - ))), - Reset::Session => block_reset_session( - event, - steplock_dir, - &checklist_name, - initial_state, - &flow, - config.allow_preview_request, - ), - } -} - -fn block_reset_always( - steplock_dir: &Path, - checklist_name: &str, - initial_state: &str, - flow: &FlowGraph, -) -> HookResponse { - let transitions: Vec = flow - .transitions - .get(initial_state) - .cloned() - .unwrap_or_default(); - let next_state = transitions - .first() - .cloned() - .filter(|_| transitions.len() == 1); - let state = SessionState { - checklist: checklist_name.to_owned(), - current_state: initial_state.to_owned(), - next_state, - transitions, - visited: vec![], - }; - audit::append( - steplock_dir, - "block", - checklist_name, - initial_state, - "always", - ); - let message = build_block_message(&state, flow, None); - eprintln!("steplock: block [{checklist_name}] state={initial_state}"); - HookResponse::Block { message } -} - -fn block_reset_session( - event: &HookEvent, - steplock_dir: &Path, - checklist_name: &str, - initial_state: &str, - flow: &FlowGraph, - allow_preview: bool, -) -> Result> { - let scope_key = get_scope_key(event, steplock_dir)?; - let session_dir = steplock_dir - .join("sessions") - .join(&scope_key) - .join(checklist_name); - fs::create_dir_all(&session_dir)?; - - let state_path = session_dir.join("state.json"); - let mut state = if state_path.exists() { - load_state(&state_path)? - } else { - init_state(checklist_name, initial_state) - }; - - // Checklist complete — approve this attempt and reset state so the - // next invocation starts the checklist fresh. - if state.is_complete() { - audit::append(steplock_dir, "complete", checklist_name, "[*]", &scope_key); - save_state(&state_path, &init_state(checklist_name, initial_state))?; - return Ok(None); - } - - // Raw transitions including [*] — stored in state.json for ack.sh validation. - let raw_transitions: Vec = flow - .transitions - .get(&state.current_state) - .cloned() - .unwrap_or_default(); - - if raw_transitions.is_empty() { - // State unknown in flow — skip silently (flow changed mid-session). - return Ok(None); - } - - // next_state: auto-advance when only one transition (may be "[*]"). - state.next_state = raw_transitions - .first() - .cloned() - .filter(|_| raw_transitions.len() == 1); - state.transitions = raw_transitions; - - save_state(&state_path, &state)?; - scripts::ensure_ack_sh(&session_dir)?; - if allow_preview { - scripts::ensure_preview_sh(&session_dir, checklist_name, flow)?; - } - - audit::append( - steplock_dir, - "block", - checklist_name, - &state.current_state, - &scope_key, - ); - eprintln!( - "steplock: block [{}] state={} session={}", - checklist_name, state.current_state, scope_key - ); - - let message = build_block_message(&state, flow, Some(&session_dir)); - Ok(Some(HookResponse::Block { message })) -} - fn cleanup_session(steplock_dir: &Path, session_id: &str) -> Result<()> { if !steplock_dir.exists() { return Ok(()); @@ -295,83 +105,11 @@ fn cleanup_session(steplock_dir: &Path, session_id: &str) -> Result<()> { Ok(()) } -fn get_scope_key(event: &HookEvent, steplock_dir: &Path) -> Result { - if !event.session_id.is_empty() { - return Ok(event.session_id.clone()); - } - let fallback_path = steplock_dir.join("sessions").join("fallback-id"); - if fallback_path.exists() { - let id = fs::read_to_string(&fallback_path)?; - return Ok(id.trim().to_owned()); - } - let id = uuid::Uuid::new_v4().to_string(); - fs::create_dir_all(steplock_dir.join("sessions"))?; - fs::write(&fallback_path, &id)?; - Ok(id) -} - -/// `session_dir` is `None` for `reset=always` checklists (no persistent ack.sh). -fn build_block_message( - state: &SessionState, - flow: &FlowGraph, - session_dir: Option<&Path>, -) -> String { - let label = flow - .labels - .get(&state.current_state) - .map_or(state.current_state.as_str(), String::as_str); - - let checklist = &state.checklist; - let step = state.visited.len() + 1; - let total = flow.order.len(); - let mut msg = format!("[{checklist}: {step}/{total}] {label}"); - msg.push_str("\n\n"); - - let visible: Vec<&String> = state - .transitions - .iter() - .filter(|s| s.as_str() != "[*]") - .collect(); - - if let Some(dir) = session_dir { - let ack = dir.join("ack.sh"); - let ack_path = ack.display(); - if visible.len() <= 1 { - let _ = write!( - msg, - "When finished, run: sh {ack_path}\nThen retry your original command." - ); - } else { - msg.push_str("When finished, run one of:\n"); - for next in &visible { - let next_label = flow.labels.get(*next).map_or(next.as_str(), String::as_str); - let _ = writeln!(msg, " sh {ack_path} {next} — {next_label}"); - } - msg.push_str("Then retry your original command."); - } - - if state.visited.is_empty() { - let preview = dir.join("preview.sh"); - if preview.exists() { - let _ = write!( - msg, - "\n(Tip: run sh {} to see all items first.)", - preview.display() - ); - } - } - } else { - // reset=always: no persistent ack.sh — agent confirms in conversation then retries. - msg.push_str("When done, retry your original command."); - } - - msg -} - #[cfg(test)] #[allow(clippy::panic, clippy::unwrap_used)] mod tests { use super::*; + use crate::state::{load_state, save_state, SessionState}; use std::collections::HashMap; use tempfile::TempDir; diff --git a/core/tests/cli.rs b/core/tests/cli.rs index f2371da..15bd627 100644 --- a/core/tests/cli.rs +++ b/core/tests/cli.rs @@ -4,7 +4,7 @@ use std::fmt::Write as FmtWrite; use std::fs; use std::io::Write; use std::path::Path; -use std::process::{Command, Stdio}; +use std::process::{Command, Output, Stdio}; use tempfile::TempDir; const STEPLOCK: &str = env!("CARGO_BIN_EXE_steplock"); @@ -40,10 +40,11 @@ fn run_steplock(root: &Path, stdin: &str) -> (i32, String, String) { run_steplock_with_global(root, stdin, "") } -/// Run the hook with `STEPLOCK_GLOBAL_DIR` set to `global` (`""` disables global checklists). -fn run_steplock_with_global(root: &Path, stdin: &str, global: &str) -> (i32, String, String) { +/// Run the hook in `dir` with `STEPLOCK_GLOBAL_DIR` set to `global` (`""` disables global +/// checklists) and `stdin` as the hook event. Returns `(exit code, stdout, stderr)`. +fn run_steplock_with_global(dir: &Path, stdin: &str, global: &str) -> (i32, String, String) { let mut child = Command::new(STEPLOCK) - .current_dir(root) + .current_dir(dir) .env("STEPLOCK_GLOBAL_DIR", global) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -66,6 +67,16 @@ fn run_steplock_with_global(root: &Path, stdin: &str, global: &str) -> (i32, Str ) } +/// Run a `steplock` subcommand with `STEPLOCK_GLOBAL_DIR` set to `global`. +fn run_subcommand_with_global(args: &[&str], dir: &Path, global: &Path) -> Output { + Command::new(STEPLOCK) + .args(args) + .current_dir(dir) + .env("STEPLOCK_GLOBAL_DIR", global) + .output() + .expect("failed to run steplock") +} + #[test] fn version_flag_prints_version() { let output = Command::new(STEPLOCK) @@ -208,22 +219,7 @@ fn hook_finds_steplock_dir_in_parent() { fs::create_dir_all(&subdir).unwrap(); let stdin = hook_event("bash", "git push origin main", "sess1"); - let mut child = Command::new(STEPLOCK) - .current_dir(&subdir) - .env("STEPLOCK_GLOBAL_DIR", "") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - child - .stdin - .take() - .unwrap() - .write_all(stdin.as_bytes()) - .unwrap(); - let output = child.wait_with_output().unwrap(); - let stdout = String::from_utf8_lossy(&output.stdout); + let (_code, stdout, _stderr) = run_steplock(&subdir, &stdin); assert!( stdout.to_lowercase().contains("block") || stdout.contains("Did you check"), "should block even from subdirectory; got: {stdout}" @@ -291,11 +287,7 @@ fn hook_ignores_global_checklist_when_disabled() { fn init_global_scaffolds_global_dir() { let global = TempDir::new().unwrap(); let target = global.path().join("steplock"); - let output = Command::new(STEPLOCK) - .args(["init", "--global"]) - .env("STEPLOCK_GLOBAL_DIR", &target) - .output() - .expect("failed to run steplock init --global"); + let output = run_subcommand_with_global(&["init", "--global"], global.path(), &target); assert!(output.status.success(), "init --global should succeed"); assert!( target.join("checklists/example-gate/config.toml").exists(), @@ -309,11 +301,8 @@ fn init_global_scaffolds_global_dir() { #[test] fn init_global_fails_when_disabled() { - let output = Command::new(STEPLOCK) - .args(["init", "--global"]) - .env("STEPLOCK_GLOBAL_DIR", "") - .output() - .expect("failed to run steplock init --global"); + let dir = TempDir::new().unwrap(); + let output = run_subcommand_with_global(&["init", "--global"], dir.path(), Path::new("")); assert_eq!( output.status.code(), Some(1), @@ -333,12 +322,7 @@ fn validate_reports_invalid_global_checklist() { "stateDiagram-v2\n [*] --> s\n s --> [*]\n s: Step\n", ) .unwrap(); - let output = Command::new(STEPLOCK) - .arg("validate") - .current_dir(project.path()) - .env("STEPLOCK_GLOBAL_DIR", global.path()) - .output() - .expect("failed to run steplock validate"); + let output = run_subcommand_with_global(&["validate"], project.path(), global.path()); assert_eq!( output.status.code(), Some(1), @@ -356,11 +340,7 @@ fn clean_global_removes_global_sessions() { let global = TempDir::new().unwrap(); let session = global.path().join("sessions/s1/gate"); fs::create_dir_all(&session).unwrap(); - let output = Command::new(STEPLOCK) - .args(["clean", "--global"]) - .env("STEPLOCK_GLOBAL_DIR", global.path()) - .output() - .expect("failed to run steplock clean --global"); + let output = run_subcommand_with_global(&["clean", "--global"], global.path(), global.path()); assert!(output.status.success(), "clean --global should succeed"); assert!( !global.path().join("sessions/s1").exists(), From b97401f52e7dc2b3d966c7767acc99d7d4cffb28 Mon Sep 17 00:00:00 2001 From: Ofek Gabay Date: Thu, 24 Sep 2026 10:27:33 +0300 Subject: [PATCH 4/4] refactor: use dirs for the home directory lookup Replace the hand-rolled HOME/USERPROFILE lookup with dirs::home_dir(), the same crate replace-homedir builds on. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- core/Cargo.lock | 47 ++++++++++++++++++++++ core/Cargo.toml | 1 + core/src/global_config.rs | 82 ++++++++++++++++----------------------- 4 files changed, 83 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index e9b50ed..f337d04 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ steplock finds the global directory in this order: 1. `$STEPLOCK_GLOBAL_DIR`. Set it to an empty string to turn global checklists off. 2. `$XDG_CONFIG_HOME/steplock`, when `XDG_CONFIG_HOME` is an absolute path. -3. `~/.config/steplock` (on Windows without `HOME`, `%USERPROFILE%\.config\steplock`). +3. `~/.config/steplock`, where `~` is your home directory (`%USERPROFILE%` on Windows). Rules: diff --git a/core/Cargo.lock b/core/Cargo.lock index 93dfdf0..3791c57 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -161,6 +161,27 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + [[package]] name = "dyn-clone" version = "1.0.20" @@ -372,6 +393,15 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -445,6 +475,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "parking_lot" version = "0.12.5" @@ -626,6 +662,16 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.19", +] + [[package]] name = "regex" version = "1.12.3" @@ -844,6 +890,7 @@ version = "0.1.0" dependencies = [ "cel-interpreter", "chrono", + "dirs", "polyhook", "proptest", "same-file", diff --git a/core/Cargo.toml b/core/Cargo.toml index ca0384f..50c21ee 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -87,6 +87,7 @@ manual_let_else = "deny" [dependencies] cel-interpreter = "0.10" chrono = { version = "0.4", features = ["serde"] } +dirs = "6" polyhook = "0.1.5" same-file = "1" serde = { version = "1", features = ["derive"] } diff --git a/core/src/global_config.rs b/core/src/global_config.rs index a32663b..ccc4aff 100644 --- a/core/src/global_config.rs +++ b/core/src/global_config.rs @@ -15,17 +15,21 @@ pub const GLOBAL_DIR_ENV: &str = "STEPLOCK_GLOBAL_DIR"; /// Lookup order: /// 1. `$STEPLOCK_GLOBAL_DIR` — used as-is; an empty value disables global checklists. /// 2. `$XDG_CONFIG_HOME/steplock` — when `XDG_CONFIG_HOME` is set to an absolute path. -/// 3. `$HOME/.config/steplock`, or `%USERPROFILE%\.config\steplock` when `HOME` is unset. +/// 3. `/.config/steplock`, where `` comes from [`dirs::home_dir`]. /// /// Returns `None` when global checklists are disabled or no home directory is known. /// The directory is not required to exist. #[must_use] pub fn global_steplock_dir() -> Option { - resolve_global_dir(|key| env::var_os(key)) + resolve_global_dir(|key| env::var_os(key), dirs::home_dir()) } -/// Resolve the global steplock directory with `var` as the environment lookup. -fn resolve_global_dir(var: impl Fn(&str) -> Option) -> Option { +/// Resolve the global steplock directory with `var` as the environment lookup and `home` +/// as the user's home directory. +fn resolve_global_dir( + var: impl Fn(&str) -> Option, + home: Option, +) -> Option { if let Some(dir) = var(GLOBAL_DIR_ENV) { return if dir.is_empty() { None @@ -38,11 +42,7 @@ fn resolve_global_dir(var: impl Fn(&str) -> Option) -> Option return Some(xdg.join("steplock")); } } - ["HOME", "USERPROFILE"] - .into_iter() - .filter_map(&var) - .find(|home| !home.is_empty()) - .map(|home| PathBuf::from(home).join(".config").join("steplock")) + home.map(|home| home.join(".config").join("steplock")) } #[cfg(test)] @@ -70,11 +70,13 @@ mod tests { #[test] fn env_override_wins() { - let dir = resolve_global_dir(lookup(&[ - (GLOBAL_DIR_ENV, abs("custom").into()), - ("XDG_CONFIG_HOME", abs("xdg").into()), - ("HOME", abs("home").into()), - ])); + let dir = resolve_global_dir( + lookup(&[ + (GLOBAL_DIR_ENV, abs("custom").into()), + ("XDG_CONFIG_HOME", abs("xdg").into()), + ]), + Some(abs("home")), + ); assert_eq!( dir, Some(abs("custom")), @@ -84,63 +86,47 @@ mod tests { #[test] fn empty_env_override_disables_global() { - let dir = resolve_global_dir(lookup(&[ - (GLOBAL_DIR_ENV, OsString::new()), - ("HOME", abs("home").into()), - ])); + let dir = resolve_global_dir( + lookup(&[(GLOBAL_DIR_ENV, OsString::new())]), + Some(abs("home")), + ); assert_eq!(dir, None, "empty STEPLOCK_GLOBAL_DIR must disable global"); } #[test] fn uses_xdg_config_home() { - let dir = resolve_global_dir(lookup(&[ - ("XDG_CONFIG_HOME", abs("xdg").into()), - ("HOME", abs("home").into()), - ])); + let dir = resolve_global_dir( + lookup(&[("XDG_CONFIG_HOME", abs("xdg").into())]), + Some(abs("home")), + ); assert_eq!(dir, Some(abs("xdg").join("steplock")), "XDG path expected"); } #[test] fn ignores_relative_xdg_config_home() { - let dir = resolve_global_dir(lookup(&[ - ("XDG_CONFIG_HOME", "relative".into()), - ("HOME", abs("home").into()), - ])); + let dir = resolve_global_dir( + lookup(&[("XDG_CONFIG_HOME", "relative".into())]), + Some(abs("home")), + ); assert_eq!( dir, Some(home_config("home")), - "relative XDG_CONFIG_HOME must fall back to HOME" + "relative XDG_CONFIG_HOME must fall back to the home directory" ); } #[test] - fn falls_back_to_home() { - let dir = resolve_global_dir(lookup(&[ - ("HOME", abs("home").into()), - ("USERPROFILE", abs("profile").into()), - ])); - assert_eq!(dir, Some(home_config("home")), "HOME wins over USERPROFILE"); - } - - #[test] - fn falls_back_to_userprofile_without_home() { - let dir = resolve_global_dir(lookup(&[ - ("HOME", OsString::new()), - ("USERPROFILE", abs("profile").into()), - ])); - assert_eq!( - dir, - Some(home_config("profile")), - "USERPROFILE fallback expected" - ); + fn falls_back_to_home_dir() { + let dir = resolve_global_dir(lookup(&[]), Some(abs("home"))); + assert_eq!(dir, Some(home_config("home")), "home fallback expected"); } #[test] fn none_without_home() { assert_eq!( - resolve_global_dir(lookup(&[])), + resolve_global_dir(lookup(&[]), None), None, - "no env means no global dir" + "no env and no home means no global dir" ); } }