From 7b4b9878957fc7745113ac08ba4e5ec70b851b2e Mon Sep 17 00:00:00 2001 From: Ofek Gabay Date: Thu, 24 Sep 2026 10:28:50 +0300 Subject: [PATCH 1/2] refactor: move unit tests into _tests.rs files Each module's inline `mod tests { ... }` now lives in a sibling `_tests.rs` (property tests in `_proptest_tests.rs`), declared with `#[path]` so test names and privacy are unchanged. - `autobins = false` so src/bin/main_tests.rs is not built as a binary. - Rename the cel_eval proptest regression file to match its new source. - Document the layout in CONTRIBUTING.md. Co-Authored-By: Claude Opus 5.5 (1M context) --- CONTRIBUTING.md | 3 +- core/Cargo.toml | 2 + ...l_eval.txt => cel_eval_proptest_tests.txt} | 0 core/src/audit.rs | 29 +- core/src/audit_tests.rs | 26 + core/src/bin/main.rs | 304 +------- core/src/bin/main_tests.rs | 301 ++++++++ core/src/cel_eval.rs | 329 +-------- core/src/cel_eval_proptest_tests.rs | 56 ++ core/src/cel_eval_tests.rs | 265 +++++++ core/src/config.rs | 65 +- core/src/config_tests.rs | 62 ++ core/src/flow.rs | 244 +------ core/src/flow_proptest_tests.rs | 55 ++ core/src/flow_tests.rs | 181 +++++ core/src/global_config.rs | 86 +-- core/src/global_config_tests.rs | 83 +++ core/src/run.rs | 690 +----------------- core/src/run_tests.rs | 687 +++++++++++++++++ core/src/scripts.rs | 107 +-- core/src/scripts_tests.rs | 104 +++ core/src/state.rs | 61 +- core/src/state_tests.rs | 58 ++ core/src/validate.rs | 86 +-- core/src/validate_tests.rs | 83 +++ 25 files changed, 1989 insertions(+), 1978 deletions(-) rename core/proptest-regressions/{cel_eval.txt => cel_eval_proptest_tests.txt} (100%) create mode 100644 core/src/audit_tests.rs create mode 100644 core/src/bin/main_tests.rs create mode 100644 core/src/cel_eval_proptest_tests.rs create mode 100644 core/src/cel_eval_tests.rs create mode 100644 core/src/config_tests.rs create mode 100644 core/src/flow_proptest_tests.rs create mode 100644 core/src/flow_tests.rs create mode 100644 core/src/global_config_tests.rs create mode 100644 core/src/run_tests.rs create mode 100644 core/src/scripts_tests.rs create mode 100644 core/src/state_tests.rs create mode 100644 core/src/validate_tests.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 35bdbce..d658559 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,7 +45,8 @@ examples/ Working reference configurations ## Making changes -- **New checklist logic** — add to `core/src/run.rs`; unit tests live in the same file +- **New checklist logic** — add to `core/src/run.rs`; unit tests live in `core/src/run_tests.rs` +- **Unit tests** — each module `.rs` keeps its unit tests in a sibling `_tests.rs`, declared with `#[cfg(test)] #[path = "_tests.rs"] mod tests;`. Property tests go in `_proptest_tests.rs` - **CEL evaluation** — `core/src/cel_eval.rs` - **Mermaid parsing** — `core/src/flow.rs` - **Shell scripts** — edit `core/scripts/ack.sh`; the binary embeds it via `include_str!` so a rebuild picks up your changes diff --git a/core/Cargo.toml b/core/Cargo.toml index 50c21ee..5d4e6e2 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -11,6 +11,8 @@ readme = "README.md" keywords = ["ai", "hooks", "quality-gate", "polyhook", "checklist"] categories = ["development-tools", "command-line-utilities"] exclude = ["Architecture.md", "deny.toml"] +# Only src/bin/main.rs is a binary; src/bin/main_tests.rs holds its unit tests. +autobins = false [package.metadata.docs.rs] all-features = true diff --git a/core/proptest-regressions/cel_eval.txt b/core/proptest-regressions/cel_eval_proptest_tests.txt similarity index 100% rename from core/proptest-regressions/cel_eval.txt rename to core/proptest-regressions/cel_eval_proptest_tests.txt diff --git a/core/src/audit.rs b/core/src/audit.rs index dff9a1d..0495e97 100644 --- a/core/src/audit.rs +++ b/core/src/audit.rs @@ -26,30 +26,5 @@ pub fn append(steplock_dir: &Path, event: &str, checklist: &str, state: &str, se #[cfg(test)] #[allow(clippy::indexing_slicing, clippy::unwrap_used)] -mod tests { - use super::*; - use std::fs; - use tempfile::TempDir; - - #[test] - fn appends_jsonl_line() { - let tmp = TempDir::new().unwrap(); - append(tmp.path(), "block", "my-checklist", "step_one", "sess-abc"); - let content = fs::read_to_string(tmp.path().join("audit.log")).unwrap(); - let parsed: serde_json::Value = serde_json::from_str(content.trim()).unwrap(); - assert_eq!(parsed["event"], "block"); - assert_eq!(parsed["checklist"], "my-checklist"); - assert_eq!(parsed["state"], "step_one"); - assert_eq!(parsed["session"], "sess-abc"); - assert!(parsed["ts"].is_string()); - } - - #[test] - fn appends_multiple_lines() { - let tmp = TempDir::new().unwrap(); - append(tmp.path(), "block", "cl", "s1", "sess"); - append(tmp.path(), "ack", "cl", "s1", "sess"); - let content = fs::read_to_string(tmp.path().join("audit.log")).unwrap(); - assert_eq!(content.lines().count(), 2); - } -} +#[path = "audit_tests.rs"] +mod tests; diff --git a/core/src/audit_tests.rs b/core/src/audit_tests.rs new file mode 100644 index 0000000..9eab971 --- /dev/null +++ b/core/src/audit_tests.rs @@ -0,0 +1,26 @@ +//! Unit tests for `audit`. +use super::*; +use std::fs; +use tempfile::TempDir; + +#[test] +fn appends_jsonl_line() { + let tmp = TempDir::new().unwrap(); + append(tmp.path(), "block", "my-checklist", "step_one", "sess-abc"); + let content = fs::read_to_string(tmp.path().join("audit.log")).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(content.trim()).unwrap(); + assert_eq!(parsed["event"], "block"); + assert_eq!(parsed["checklist"], "my-checklist"); + assert_eq!(parsed["state"], "step_one"); + assert_eq!(parsed["session"], "sess-abc"); + assert!(parsed["ts"].is_string()); +} + +#[test] +fn appends_multiple_lines() { + let tmp = TempDir::new().unwrap(); + append(tmp.path(), "block", "cl", "s1", "sess"); + append(tmp.path(), "ack", "cl", "s1", "sess"); + let content = fs::read_to_string(tmp.path().join("audit.log")).unwrap(); + assert_eq!(content.lines().count(), 2); +} diff --git a/core/src/bin/main.rs b/core/src/bin/main.rs index 42adb8a..19bafc8 100644 --- a/core/src/bin/main.rs +++ b/core/src/bin/main.rs @@ -293,305 +293,5 @@ fn find_repo_root_from(start: &Path) -> Option { #[cfg(test)] #[allow(clippy::unwrap_used)] -mod tests { - use super::*; - use std::fs; - use tempfile::TempDir; - - fn claude_stdin(cmd: &str, session: &str) -> String { - serde_json::json!({ - "hook_event_name": "PreToolUse", - "tool_name": "Bash", - "tool_input": { "command": cmd }, - "tool_output": {}, - "session_id": session - }) - .to_string() - } - - fn setup_checklist(root: &Path) { - let cl_dir = root.join(".steplock/checklists/quality-gate"); - fs::create_dir_all(&cl_dir).unwrap(); - fs::write( - cl_dir.join("config.toml"), - r#"on_event = "tool:before" -on_tool = "bash" -match_input = "input.command.contains('git push')" -reset = "session" -"#, - ) - .unwrap(); - fs::write( - cl_dir.join("flow.mmd"), - r"stateDiagram-v2 - [*] --> check - check --> [*] - check: Did you check? -", - ) - .unwrap(); - } - - #[test] - fn polyhook_event_maps_correctly() { - let stdin = claude_stdin("git push origin main", "s1"); - let ph_event = parse::parse_event(stdin.as_bytes()).unwrap(); - let event = polyhook_to_hook_event(ph_event); - assert_eq!(event.event, "tool:before"); - assert_eq!(event.tool, "bash"); - assert_eq!(event.session_id, "s1"); - assert_eq!(event.caller, "claude-code"); - assert_eq!( - event.input.get("command").and_then(|v| v.as_str()), - Some("git push origin main") - ); - } - - #[test] - fn run_app_approves_non_matching_command() { - 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(), None).unwrap(); - assert!(matches!(resp, polyhook::HookResponse::ApproveResponse(_))); - } - - #[test] - fn run_app_blocks_matching_command() { - 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(), 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(), None); - assert!(err.is_err()); - assert!(err - .unwrap_err() - .contains("steplock: failed to read hook input")); - } - - #[test] - fn run_app_error_on_invalid_cel_expression() { - let tmp = TempDir::new().unwrap(); - let cl_dir = tmp.path().join(".steplock/checklists/bad-gate"); - fs::create_dir_all(&cl_dir).unwrap(); - fs::write( - cl_dir.join("config.toml"), - r#"on_event = "tool:before" -on_tool = "bash" -match_input = "!!!invalid cel!!!" -reset = "session" -"#, - ) - .unwrap(); - fs::write( - cl_dir.join("flow.mmd"), - "stateDiagram-v2\n [*] --> check\n check --> [*]\n check: Check\n", - ) - .unwrap(); - let stdin = claude_stdin("anything", "s1"); - let err = run_app(stdin.as_bytes(), tmp.path(), None); - assert!(err.is_err()); - assert!(err.unwrap_err().contains("steplock: error:")); - } - - #[test] - fn find_repo_root_finds_steplock_dir() { - let tmp = TempDir::new().unwrap(); - fs::create_dir(tmp.path().join(".steplock")).unwrap(); - let root = find_repo_root_from(tmp.path()).unwrap(); - assert_eq!(root, tmp.path()); - } - - #[test] - fn find_repo_root_walks_up() { - let tmp = TempDir::new().unwrap(); - fs::create_dir(tmp.path().join(".steplock")).unwrap(); - let subdir = tmp.path().join("a/b/c"); - fs::create_dir_all(&subdir).unwrap(); - let root = find_repo_root_from(&subdir).unwrap(); - assert_eq!(root, tmp.path()); - } - - #[test] - fn find_repo_root_returns_none_when_not_found() { - let tmp = TempDir::new().unwrap(); - let result = find_repo_root_from(tmp.path()); - assert!(result.is_none()); - } - - #[test] - fn init_creates_checklists_dir_and_gitignore() { - let tmp = TempDir::new().unwrap(); - run_init(tmp.path()).unwrap(); - assert!(tmp.path().join(".steplock/checklists").is_dir()); - let gitignore = fs::read_to_string(tmp.path().join(".steplock/.gitignore")).unwrap(); - assert!(gitignore.contains("sessions/")); - assert!(gitignore.contains("audit.log")); - } - - #[test] - fn init_scaffolds_sample_checklist() { - let tmp = TempDir::new().unwrap(); - run_init(tmp.path()).unwrap(); - let sample = tmp.path().join(".steplock/checklists/example-gate"); - assert!(sample.join("config.toml").exists()); - assert!(sample.join("flow.mmd").exists()); - let cfg = fs::read_to_string(sample.join("config.toml")).unwrap(); - assert!(cfg.contains("git push")); - let flow = fs::read_to_string(sample.join("flow.mmd")).unwrap(); - assert!(flow.contains("stateDiagram-v2")); - } - - #[test] - fn init_sample_checklist_is_valid() { - 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(), None).unwrap(); - assert!(matches!(resp, polyhook::HookResponse::BlockResponse(_))); - } - - #[test] - fn init_is_idempotent_when_checklists_exists() { - let tmp = TempDir::new().unwrap(); - fs::create_dir_all(tmp.path().join(".steplock/checklists")).unwrap(); - run_init(tmp.path()).unwrap(); - } - - #[test] - fn clean_removes_session_dirs() { - let tmp = TempDir::new().unwrap(); - fs::create_dir_all(tmp.path().join(".steplock/sessions/sess-abc/gate")).unwrap(); - fs::create_dir_all(tmp.path().join(".steplock/sessions/sess-xyz/gate")).unwrap(); - run_clean(tmp.path()).unwrap(); - assert!(!tmp.path().join(".steplock/sessions/sess-abc").exists()); - assert!(!tmp.path().join(".steplock/sessions/sess-xyz").exists()); - } - - #[test] - fn clean_removes_fallback_id_file() { - let tmp = TempDir::new().unwrap(); - let sessions = tmp.path().join(".steplock/sessions"); - fs::create_dir_all(&sessions).unwrap(); - fs::write(sessions.join("fallback-id"), "some-uuid").unwrap(); - run_clean(tmp.path()).unwrap(); - assert!(!sessions.join("fallback-id").exists()); - } - - #[test] - fn clean_is_noop_when_no_sessions_dir() { - let tmp = TempDir::new().unwrap(); - fs::create_dir_all(tmp.path().join(".steplock/checklists")).unwrap(); - run_clean(tmp.path()).unwrap(); - } - - #[test] - fn clean_is_noop_when_no_steplock_dir() { - let tmp = TempDir::new().unwrap(); - run_clean(tmp.path()).unwrap(); - } - - #[test] - fn clean_leaves_sessions_dir_intact() { - let tmp = TempDir::new().unwrap(); - let sessions = tmp.path().join(".steplock/sessions"); - fs::create_dir_all(sessions.join("sess-1/gate")).unwrap(); - run_clean(tmp.path()).unwrap(); - assert!(sessions.exists()); - } - - #[test] - fn validate_returns_true_when_no_checklists_dir() { - let tmp = TempDir::new().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(), None).unwrap()); - } - - #[test] - fn validate_returns_true_for_valid_checklist() { - let tmp = TempDir::new().unwrap(); - setup_checklist(tmp.path()); - assert!(run_validate(tmp.path(), None).unwrap()); - } - - #[test] - fn validate_returns_false_when_config_toml_missing() { - let tmp = TempDir::new().unwrap(); - let cl_dir = tmp.path().join(".steplock/checklists/no-config"); - fs::create_dir_all(&cl_dir).unwrap(); - fs::write( - cl_dir.join("flow.mmd"), - "stateDiagram-v2\n [*] --> s\n s --> [*]\n s: Step\n", - ) - .unwrap(); - assert!(!run_validate(tmp.path(), None).unwrap()); - } - - #[test] - fn validate_returns_false_when_flow_mmd_missing() { - let tmp = TempDir::new().unwrap(); - let cl_dir = tmp.path().join(".steplock/checklists/no-flow"); - fs::create_dir_all(&cl_dir).unwrap(); - fs::write( - cl_dir.join("config.toml"), - "on_event = \"tool:before\"\nreset = \"session\"\n", - ) - .unwrap(); - assert!(!run_validate(tmp.path(), None).unwrap()); - } - - #[test] - fn validate_returns_false_for_invalid_config_toml() { - let tmp = TempDir::new().unwrap(); - let cl_dir = tmp.path().join(".steplock/checklists/bad-config"); - fs::create_dir_all(&cl_dir).unwrap(); - fs::write(cl_dir.join("config.toml"), "not valid toml !!!").unwrap(); - fs::write( - cl_dir.join("flow.mmd"), - "stateDiagram-v2\n [*] --> s\n s --> [*]\n s: Step\n", - ) - .unwrap(); - assert!(!run_validate(tmp.path(), None).unwrap()); - } - - #[test] - fn validate_returns_false_for_invalid_flow_mmd() { - let tmp = TempDir::new().unwrap(); - let cl_dir = tmp.path().join(".steplock/checklists/bad-flow"); - fs::create_dir_all(&cl_dir).unwrap(); - fs::write( - cl_dir.join("config.toml"), - "on_event = \"tool:before\"\nreset = \"session\"\n", - ) - .unwrap(); - fs::write(cl_dir.join("flow.mmd"), "stateDiagram-v2\n a --> b\n").unwrap(); - assert!(!run_validate(tmp.path(), None).unwrap()); - } - - #[test] - fn validate_continues_checking_all_checklists_after_failure() { - let tmp = TempDir::new().unwrap(); - setup_checklist(tmp.path()); - let bad_dir = tmp.path().join(".steplock/checklists/0-bad"); - fs::create_dir_all(&bad_dir).unwrap(); - fs::write(bad_dir.join("config.toml"), "not valid").unwrap(); - fs::write( - bad_dir.join("flow.mmd"), - "stateDiagram-v2\n [*] --> s\n s --> [*]\n s: Step\n", - ) - .unwrap(); - assert!(!run_validate(tmp.path(), None).unwrap()); - } -} +#[path = "main_tests.rs"] +mod tests; diff --git a/core/src/bin/main_tests.rs b/core/src/bin/main_tests.rs new file mode 100644 index 0000000..9b05b13 --- /dev/null +++ b/core/src/bin/main_tests.rs @@ -0,0 +1,301 @@ +//! Unit tests for `main`. +use super::*; +use std::fs; +use tempfile::TempDir; + +fn claude_stdin(cmd: &str, session: &str) -> String { + serde_json::json!({ + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": { "command": cmd }, + "tool_output": {}, + "session_id": session + }) + .to_string() +} + +fn setup_checklist(root: &Path) { + let cl_dir = root.join(".steplock/checklists/quality-gate"); + fs::create_dir_all(&cl_dir).unwrap(); + fs::write( + cl_dir.join("config.toml"), + r#"on_event = "tool:before" +on_tool = "bash" +match_input = "input.command.contains('git push')" +reset = "session" +"#, + ) + .unwrap(); + fs::write( + cl_dir.join("flow.mmd"), + r"stateDiagram-v2 + [*] --> check + check --> [*] + check: Did you check? +", + ) + .unwrap(); +} + +#[test] +fn polyhook_event_maps_correctly() { + let stdin = claude_stdin("git push origin main", "s1"); + let ph_event = parse::parse_event(stdin.as_bytes()).unwrap(); + let event = polyhook_to_hook_event(ph_event); + assert_eq!(event.event, "tool:before"); + assert_eq!(event.tool, "bash"); + assert_eq!(event.session_id, "s1"); + assert_eq!(event.caller, "claude-code"); + assert_eq!( + event.input.get("command").and_then(|v| v.as_str()), + Some("git push origin main") + ); +} + +#[test] +fn run_app_approves_non_matching_command() { + 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(), None).unwrap(); + assert!(matches!(resp, polyhook::HookResponse::ApproveResponse(_))); +} + +#[test] +fn run_app_blocks_matching_command() { + 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(), 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(), None); + assert!(err.is_err()); + assert!(err + .unwrap_err() + .contains("steplock: failed to read hook input")); +} + +#[test] +fn run_app_error_on_invalid_cel_expression() { + let tmp = TempDir::new().unwrap(); + let cl_dir = tmp.path().join(".steplock/checklists/bad-gate"); + fs::create_dir_all(&cl_dir).unwrap(); + fs::write( + cl_dir.join("config.toml"), + r#"on_event = "tool:before" +on_tool = "bash" +match_input = "!!!invalid cel!!!" +reset = "session" +"#, + ) + .unwrap(); + fs::write( + cl_dir.join("flow.mmd"), + "stateDiagram-v2\n [*] --> check\n check --> [*]\n check: Check\n", + ) + .unwrap(); + let stdin = claude_stdin("anything", "s1"); + let err = run_app(stdin.as_bytes(), tmp.path(), None); + assert!(err.is_err()); + assert!(err.unwrap_err().contains("steplock: error:")); +} + +#[test] +fn find_repo_root_finds_steplock_dir() { + let tmp = TempDir::new().unwrap(); + fs::create_dir(tmp.path().join(".steplock")).unwrap(); + let root = find_repo_root_from(tmp.path()).unwrap(); + assert_eq!(root, tmp.path()); +} + +#[test] +fn find_repo_root_walks_up() { + let tmp = TempDir::new().unwrap(); + fs::create_dir(tmp.path().join(".steplock")).unwrap(); + let subdir = tmp.path().join("a/b/c"); + fs::create_dir_all(&subdir).unwrap(); + let root = find_repo_root_from(&subdir).unwrap(); + assert_eq!(root, tmp.path()); +} + +#[test] +fn find_repo_root_returns_none_when_not_found() { + let tmp = TempDir::new().unwrap(); + let result = find_repo_root_from(tmp.path()); + assert!(result.is_none()); +} + +#[test] +fn init_creates_checklists_dir_and_gitignore() { + let tmp = TempDir::new().unwrap(); + run_init(tmp.path()).unwrap(); + assert!(tmp.path().join(".steplock/checklists").is_dir()); + let gitignore = fs::read_to_string(tmp.path().join(".steplock/.gitignore")).unwrap(); + assert!(gitignore.contains("sessions/")); + assert!(gitignore.contains("audit.log")); +} + +#[test] +fn init_scaffolds_sample_checklist() { + let tmp = TempDir::new().unwrap(); + run_init(tmp.path()).unwrap(); + let sample = tmp.path().join(".steplock/checklists/example-gate"); + assert!(sample.join("config.toml").exists()); + assert!(sample.join("flow.mmd").exists()); + let cfg = fs::read_to_string(sample.join("config.toml")).unwrap(); + assert!(cfg.contains("git push")); + let flow = fs::read_to_string(sample.join("flow.mmd")).unwrap(); + assert!(flow.contains("stateDiagram-v2")); +} + +#[test] +fn init_sample_checklist_is_valid() { + 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(), None).unwrap(); + assert!(matches!(resp, polyhook::HookResponse::BlockResponse(_))); +} + +#[test] +fn init_is_idempotent_when_checklists_exists() { + let tmp = TempDir::new().unwrap(); + fs::create_dir_all(tmp.path().join(".steplock/checklists")).unwrap(); + run_init(tmp.path()).unwrap(); +} + +#[test] +fn clean_removes_session_dirs() { + let tmp = TempDir::new().unwrap(); + fs::create_dir_all(tmp.path().join(".steplock/sessions/sess-abc/gate")).unwrap(); + fs::create_dir_all(tmp.path().join(".steplock/sessions/sess-xyz/gate")).unwrap(); + run_clean(tmp.path()).unwrap(); + assert!(!tmp.path().join(".steplock/sessions/sess-abc").exists()); + assert!(!tmp.path().join(".steplock/sessions/sess-xyz").exists()); +} + +#[test] +fn clean_removes_fallback_id_file() { + let tmp = TempDir::new().unwrap(); + let sessions = tmp.path().join(".steplock/sessions"); + fs::create_dir_all(&sessions).unwrap(); + fs::write(sessions.join("fallback-id"), "some-uuid").unwrap(); + run_clean(tmp.path()).unwrap(); + assert!(!sessions.join("fallback-id").exists()); +} + +#[test] +fn clean_is_noop_when_no_sessions_dir() { + let tmp = TempDir::new().unwrap(); + fs::create_dir_all(tmp.path().join(".steplock/checklists")).unwrap(); + run_clean(tmp.path()).unwrap(); +} + +#[test] +fn clean_is_noop_when_no_steplock_dir() { + let tmp = TempDir::new().unwrap(); + run_clean(tmp.path()).unwrap(); +} + +#[test] +fn clean_leaves_sessions_dir_intact() { + let tmp = TempDir::new().unwrap(); + let sessions = tmp.path().join(".steplock/sessions"); + fs::create_dir_all(sessions.join("sess-1/gate")).unwrap(); + run_clean(tmp.path()).unwrap(); + assert!(sessions.exists()); +} + +#[test] +fn validate_returns_true_when_no_checklists_dir() { + let tmp = TempDir::new().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(), None).unwrap()); +} + +#[test] +fn validate_returns_true_for_valid_checklist() { + let tmp = TempDir::new().unwrap(); + setup_checklist(tmp.path()); + assert!(run_validate(tmp.path(), None).unwrap()); +} + +#[test] +fn validate_returns_false_when_config_toml_missing() { + let tmp = TempDir::new().unwrap(); + let cl_dir = tmp.path().join(".steplock/checklists/no-config"); + fs::create_dir_all(&cl_dir).unwrap(); + fs::write( + cl_dir.join("flow.mmd"), + "stateDiagram-v2\n [*] --> s\n s --> [*]\n s: Step\n", + ) + .unwrap(); + assert!(!run_validate(tmp.path(), None).unwrap()); +} + +#[test] +fn validate_returns_false_when_flow_mmd_missing() { + let tmp = TempDir::new().unwrap(); + let cl_dir = tmp.path().join(".steplock/checklists/no-flow"); + fs::create_dir_all(&cl_dir).unwrap(); + fs::write( + cl_dir.join("config.toml"), + "on_event = \"tool:before\"\nreset = \"session\"\n", + ) + .unwrap(); + assert!(!run_validate(tmp.path(), None).unwrap()); +} + +#[test] +fn validate_returns_false_for_invalid_config_toml() { + let tmp = TempDir::new().unwrap(); + let cl_dir = tmp.path().join(".steplock/checklists/bad-config"); + fs::create_dir_all(&cl_dir).unwrap(); + fs::write(cl_dir.join("config.toml"), "not valid toml !!!").unwrap(); + fs::write( + cl_dir.join("flow.mmd"), + "stateDiagram-v2\n [*] --> s\n s --> [*]\n s: Step\n", + ) + .unwrap(); + assert!(!run_validate(tmp.path(), None).unwrap()); +} + +#[test] +fn validate_returns_false_for_invalid_flow_mmd() { + let tmp = TempDir::new().unwrap(); + let cl_dir = tmp.path().join(".steplock/checklists/bad-flow"); + fs::create_dir_all(&cl_dir).unwrap(); + fs::write( + cl_dir.join("config.toml"), + "on_event = \"tool:before\"\nreset = \"session\"\n", + ) + .unwrap(); + fs::write(cl_dir.join("flow.mmd"), "stateDiagram-v2\n a --> b\n").unwrap(); + assert!(!run_validate(tmp.path(), None).unwrap()); +} + +#[test] +fn validate_continues_checking_all_checklists_after_failure() { + let tmp = TempDir::new().unwrap(); + setup_checklist(tmp.path()); + let bad_dir = tmp.path().join(".steplock/checklists/0-bad"); + fs::create_dir_all(&bad_dir).unwrap(); + fs::write(bad_dir.join("config.toml"), "not valid").unwrap(); + fs::write( + bad_dir.join("flow.mmd"), + "stateDiagram-v2\n [*] --> s\n s --> [*]\n s: Step\n", + ) + .unwrap(); + assert!(!run_validate(tmp.path(), None).unwrap()); +} diff --git a/core/src/cel_eval.rs b/core/src/cel_eval.rs index 5e29e18..dac2844 100644 --- a/core/src/cel_eval.rs +++ b/core/src/cel_eval.rs @@ -135,331 +135,10 @@ const fn is_truthy(v: &Value) -> bool { #[cfg(test)] #[allow(clippy::unwrap_used)] -mod tests { - use super::*; - use serde_json::json; - - fn event_with_cmd(cmd: &str) -> HookEvent { - let mut input = HashMap::new(); - input.insert("command".to_owned(), json!(cmd)); - HookEvent { - event: "tool:before".to_owned(), - tool: "bash".to_owned(), - input, - output: HashMap::new(), - session_id: "s1".to_owned(), - caller: "claude-code".to_owned(), - } - } - - fn event_with_path(path: &str) -> HookEvent { - let mut input = HashMap::new(); - input.insert("path".to_owned(), json!(path)); - HookEvent { - event: "tool:before".to_owned(), - tool: "write_file".to_owned(), - input, - output: HashMap::new(), - session_id: "s1".to_owned(), - caller: "cursor".to_owned(), - } - } - - #[test] - fn none_expr_matches_all() { - let ev = event_with_cmd("ls"); - assert!(matches_event(&ev, &None).unwrap()); - } - - #[test] - fn empty_expr_matches_all() { - let ev = event_with_cmd("ls"); - assert!(matches_event(&ev, &Some(" ".to_owned())).unwrap()); - } - - #[test] - fn expr_true_for_matching_command() { - let ev = event_with_cmd("git push origin main"); - assert!( - matches_event(&ev, &Some("input.command.contains('git push')".to_owned())).unwrap() - ); - } - - #[test] - fn expr_false_for_non_matching_command() { - let ev = event_with_cmd("ls -la"); - assert!( - !matches_event(&ev, &Some("input.command.contains('git push')".to_owned())).unwrap() - ); - } - - #[test] - fn expr_uses_event_tool() { - let ev = event_with_cmd("anything"); - assert!(matches_event(&ev, &Some("event.tool == 'bash'".to_owned())).unwrap()); - } - - #[test] - fn expr_uses_event_caller() { - let ev = event_with_path("/etc/hosts"); - assert!(matches_event(&ev, &Some("event.caller == 'cursor'".to_owned())).unwrap()); - } - - #[test] - fn expr_uses_event_event_field() { - let ev = event_with_cmd("ls"); - assert!(matches_event(&ev, &Some("event.event == 'tool:before'".to_owned())).unwrap()); - } - - #[test] - fn expr_path_starts_with() { - let ev = event_with_path("/etc/hosts"); - assert!(matches_event(&ev, &Some("input.path.startsWith('/etc')".to_owned())).unwrap()); - } - - #[test] - fn invalid_cel_returns_error() { - let ev = event_with_cmd("ls"); - let err = matches_event(&ev, &Some("!!!invalid".to_owned())); - err.unwrap_err(); - } - - #[test] - fn json_null_input_converts() { - let mut input = HashMap::new(); - input.insert("val".to_owned(), serde_json::Value::Null); - let ev = HookEvent { - event: "tool:before".to_owned(), - tool: "bash".to_owned(), - input, - output: HashMap::new(), - session_id: "s".to_owned(), - caller: "test".to_owned(), - }; - // null is falsy — expr checking tool name should still work - assert!(matches_event(&ev, &Some("event.tool == 'bash'".to_owned())).unwrap()); - } - - #[test] - fn json_bool_input_converts() { - let mut input = HashMap::new(); - input.insert("flag".to_owned(), json!(true)); - let ev = HookEvent { - event: "tool:before".to_owned(), - tool: "bash".to_owned(), - input, - output: HashMap::new(), - session_id: "s".to_owned(), - caller: "test".to_owned(), - }; - assert!(matches_event(&ev, &Some("input.flag".to_owned())).unwrap()); - } - - #[test] - fn json_float_input_converts() { - let mut input = HashMap::new(); - input.insert("val".to_owned(), json!(1.23f64)); - let ev = HookEvent { - event: "tool:before".to_owned(), - tool: "bash".to_owned(), - input, - output: HashMap::new(), - session_id: "s".to_owned(), - caller: "test".to_owned(), - }; - assert!(matches_event(&ev, &Some("event.tool == 'bash'".to_owned())).unwrap()); - } - - #[test] - fn json_array_input_converts() { - let mut input = HashMap::new(); - input.insert("items".to_owned(), json!(["a", "b"])); - let ev = HookEvent { - event: "tool:before".to_owned(), - tool: "bash".to_owned(), - input, - output: HashMap::new(), - session_id: "s".to_owned(), - caller: "test".to_owned(), - }; - assert!(matches_event(&ev, &Some("event.tool == 'bash'".to_owned())).unwrap()); - } - - #[test] - fn json_nested_object_converts() { - let mut input = HashMap::new(); - input.insert("meta".to_owned(), json!({"key": "value"})); - let ev = HookEvent { - event: "tool:before".to_owned(), - tool: "bash".to_owned(), - input, - output: HashMap::new(), - session_id: "s".to_owned(), - caller: "test".to_owned(), - }; - assert!(matches_event(&ev, &Some("event.tool == 'bash'".to_owned())).unwrap()); - } - - #[test] - fn output_map_is_accessible() { - let mut output = HashMap::new(); - output.insert("exit_code".to_owned(), json!(0)); - let ev = HookEvent { - event: "tool:after".to_owned(), - tool: "bash".to_owned(), - input: HashMap::new(), - output, - session_id: "s".to_owned(), - caller: "test".to_owned(), - }; - assert!(matches_event(&ev, &Some("event.tool == 'bash'".to_owned())).unwrap()); - } - - #[test] - fn non_bool_truthy_result_returns_true() { - // CEL expression returning a string (non-bool, non-null) → truthy - let ev = event_with_cmd("ls"); - assert!(matches_event(&ev, &Some("event.tool".to_owned())).unwrap()); - } - - #[test] - fn null_result_returns_false() { - // CEL `null` literal returns Value::Null → falsy - let ev = event_with_cmd("ls"); - assert!(!matches_event(&ev, &Some("null".to_owned())).unwrap()); - } - - #[test] - fn command_words_matches_push_subcommand() { - let ev = event_with_cmd("git -C /some/path push origin main"); - assert!(matches_event( - &ev, - &Some("input.command_words.exists(x, x == 'push')".to_owned()) - ) - .unwrap()); - } - - #[test] - fn command_words_does_not_match_path_containing_push() { - let ev = event_with_cmd("git add .steplock/checklists/pre-push/config.toml"); - assert!(!matches_event( - &ev, - &Some("input.command_words.exists(x, x == 'push')".to_owned()) - ) - .unwrap()); - } - - #[test] - fn command_words_matches_commit_with_dash_c_flag() { - let ev = event_with_cmd("git -C /repo commit -m \"fix bug\""); - assert!(matches_event( - &ev, - &Some("input.command_words.exists(x, x == 'commit')".to_owned()) - ) - .unwrap()); - } - - #[test] - fn command_words_does_not_match_path_containing_commit() { - let ev = event_with_cmd("git add .steplock/checklists/pre-commit/config.toml"); - assert!(!matches_event( - &ev, - &Some("input.command_words.exists(x, x == 'commit')".to_owned()) - ) - .unwrap()); - } - - // Regression: `command_words` includes message text, so "push" in a commit - // body must not trigger the pre-push gate. The guard is to also require - // that "commit" is absent (real git push commands never have that word). - #[test] - fn pre_push_expr_does_not_trigger_on_commit_with_push_in_message() { - let ev = event_with_cmd("git -C /repo commit -m \"add CI push path filter\""); - let safe_expr = "input.command_words.exists(x, x == 'push') && !input.command_words.exists(x, x == 'commit')"; - assert!(!matches_event(&ev, &Some(safe_expr.to_owned())).unwrap()); - } - - #[test] - fn pre_push_expr_still_matches_git_push() { - let ev = event_with_cmd("git -C /repo push origin main"); - let safe_expr = "input.command_words.exists(x, x == 'push') && !input.command_words.exists(x, x == 'commit')"; - assert!(matches_event(&ev, &Some(safe_expr.to_owned())).unwrap()); - } - - // Regression: `command_words` includes flags and body text passed to other - // commands (e.g. `gh pr create --body "...commit..."`) — the safe guard is - // to also require that "git" is present so non-git tools don't match. - #[test] - fn pre_commit_expr_does_not_trigger_on_gh_pr_create_with_commit_in_body() { - let ev = event_with_cmd("gh pr create --body \"A commit was merged\""); - let safe_expr = "input.command_words.exists(x, x == 'commit') && input.command_words.exists(x, x == 'git')"; - assert!(!matches_event(&ev, &Some(safe_expr.to_owned())).unwrap()); - } - - #[test] - fn pre_commit_expr_still_matches_git_commit() { - let ev = event_with_cmd("git -C /repo commit -m \"fix bug\""); - let safe_expr = "input.command_words.exists(x, x == 'commit') && input.command_words.exists(x, x == 'git')"; - assert!(matches_event(&ev, &Some(safe_expr.to_owned())).unwrap()); - } -} +#[path = "cel_eval_tests.rs"] +mod tests; #[cfg(test)] #[allow(clippy::unwrap_used)] -mod proptest_tests { - use std::collections::HashMap; - - use proptest::prelude::*; - - use super::*; - use crate::state::HookEvent; - - fn arb_event() -> impl Strategy { - ( - any::(), - any::(), - any::(), - any::(), - ) - .prop_map(|(event, tool, session_id, caller)| HookEvent { - event, - tool, - session_id, - caller, - input: HashMap::new(), - output: HashMap::new(), - }) - } - - proptest! { - /// `matches_event` never panics for arbitrary events and expressions. - #[test] - fn never_panics(event in arb_event(), expr in any::>()) { - let _: crate::Result = matches_event(&event, &expr); - } - - /// `None` expression always approves regardless of event content. - #[test] - fn none_always_approves(event in arb_event()) { - prop_assert!(matches_event(&event, &None).unwrap()); - } - - /// Whitespace-only expression always approves. - #[test] - fn whitespace_always_approves(event in arb_event(), ws in " *") { - prop_assert!(matches_event(&event, &Some(ws)).unwrap()); - } - - /// CEL literal `true` matches any event. - #[test] - fn cel_true_always_matches(event in arb_event()) { - prop_assert!(matches_event(&event, &Some("true".to_owned())).unwrap()); - } - - /// CEL literal `false` never matches any event. - #[test] - fn cel_false_never_matches(event in arb_event()) { - prop_assert!(!matches_event(&event, &Some("false".to_owned())).unwrap()); - } - } -} +#[path = "cel_eval_proptest_tests.rs"] +mod proptest_tests; diff --git a/core/src/cel_eval_proptest_tests.rs b/core/src/cel_eval_proptest_tests.rs new file mode 100644 index 0000000..c78312f --- /dev/null +++ b/core/src/cel_eval_proptest_tests.rs @@ -0,0 +1,56 @@ +//! Property-based tests for `cel_eval`. +use std::collections::HashMap; + +use proptest::prelude::*; + +use super::*; +use crate::state::HookEvent; + +fn arb_event() -> impl Strategy { + ( + any::(), + any::(), + any::(), + any::(), + ) + .prop_map(|(event, tool, session_id, caller)| HookEvent { + event, + tool, + session_id, + caller, + input: HashMap::new(), + output: HashMap::new(), + }) +} + +proptest! { + /// `matches_event` never panics for arbitrary events and expressions. + #[test] + fn never_panics(event in arb_event(), expr in any::>()) { + let _: crate::Result = matches_event(&event, &expr); + } + + /// `None` expression always approves regardless of event content. + #[test] + fn none_always_approves(event in arb_event()) { + prop_assert!(matches_event(&event, &None).unwrap()); + } + + /// Whitespace-only expression always approves. + #[test] + fn whitespace_always_approves(event in arb_event(), ws in " *") { + prop_assert!(matches_event(&event, &Some(ws)).unwrap()); + } + + /// CEL literal `true` matches any event. + #[test] + fn cel_true_always_matches(event in arb_event()) { + prop_assert!(matches_event(&event, &Some("true".to_owned())).unwrap()); + } + + /// CEL literal `false` never matches any event. + #[test] + fn cel_false_never_matches(event in arb_event()) { + prop_assert!(!matches_event(&event, &Some("false".to_owned())).unwrap()); + } +} diff --git a/core/src/cel_eval_tests.rs b/core/src/cel_eval_tests.rs new file mode 100644 index 0000000..adbba20 --- /dev/null +++ b/core/src/cel_eval_tests.rs @@ -0,0 +1,265 @@ +//! Unit tests for `cel_eval`. +use super::*; +use serde_json::json; + +fn event_with_cmd(cmd: &str) -> HookEvent { + let mut input = HashMap::new(); + input.insert("command".to_owned(), json!(cmd)); + HookEvent { + event: "tool:before".to_owned(), + tool: "bash".to_owned(), + input, + output: HashMap::new(), + session_id: "s1".to_owned(), + caller: "claude-code".to_owned(), + } +} + +fn event_with_path(path: &str) -> HookEvent { + let mut input = HashMap::new(); + input.insert("path".to_owned(), json!(path)); + HookEvent { + event: "tool:before".to_owned(), + tool: "write_file".to_owned(), + input, + output: HashMap::new(), + session_id: "s1".to_owned(), + caller: "cursor".to_owned(), + } +} + +#[test] +fn none_expr_matches_all() { + let ev = event_with_cmd("ls"); + assert!(matches_event(&ev, &None).unwrap()); +} + +#[test] +fn empty_expr_matches_all() { + let ev = event_with_cmd("ls"); + assert!(matches_event(&ev, &Some(" ".to_owned())).unwrap()); +} + +#[test] +fn expr_true_for_matching_command() { + let ev = event_with_cmd("git push origin main"); + assert!(matches_event(&ev, &Some("input.command.contains('git push')".to_owned())).unwrap()); +} + +#[test] +fn expr_false_for_non_matching_command() { + let ev = event_with_cmd("ls -la"); + assert!(!matches_event(&ev, &Some("input.command.contains('git push')".to_owned())).unwrap()); +} + +#[test] +fn expr_uses_event_tool() { + let ev = event_with_cmd("anything"); + assert!(matches_event(&ev, &Some("event.tool == 'bash'".to_owned())).unwrap()); +} + +#[test] +fn expr_uses_event_caller() { + let ev = event_with_path("/etc/hosts"); + assert!(matches_event(&ev, &Some("event.caller == 'cursor'".to_owned())).unwrap()); +} + +#[test] +fn expr_uses_event_event_field() { + let ev = event_with_cmd("ls"); + assert!(matches_event(&ev, &Some("event.event == 'tool:before'".to_owned())).unwrap()); +} + +#[test] +fn expr_path_starts_with() { + let ev = event_with_path("/etc/hosts"); + assert!(matches_event(&ev, &Some("input.path.startsWith('/etc')".to_owned())).unwrap()); +} + +#[test] +fn invalid_cel_returns_error() { + let ev = event_with_cmd("ls"); + let err = matches_event(&ev, &Some("!!!invalid".to_owned())); + err.unwrap_err(); +} + +#[test] +fn json_null_input_converts() { + let mut input = HashMap::new(); + input.insert("val".to_owned(), serde_json::Value::Null); + let ev = HookEvent { + event: "tool:before".to_owned(), + tool: "bash".to_owned(), + input, + output: HashMap::new(), + session_id: "s".to_owned(), + caller: "test".to_owned(), + }; + // null is falsy — expr checking tool name should still work + assert!(matches_event(&ev, &Some("event.tool == 'bash'".to_owned())).unwrap()); +} + +#[test] +fn json_bool_input_converts() { + let mut input = HashMap::new(); + input.insert("flag".to_owned(), json!(true)); + let ev = HookEvent { + event: "tool:before".to_owned(), + tool: "bash".to_owned(), + input, + output: HashMap::new(), + session_id: "s".to_owned(), + caller: "test".to_owned(), + }; + assert!(matches_event(&ev, &Some("input.flag".to_owned())).unwrap()); +} + +#[test] +fn json_float_input_converts() { + let mut input = HashMap::new(); + input.insert("val".to_owned(), json!(1.23f64)); + let ev = HookEvent { + event: "tool:before".to_owned(), + tool: "bash".to_owned(), + input, + output: HashMap::new(), + session_id: "s".to_owned(), + caller: "test".to_owned(), + }; + assert!(matches_event(&ev, &Some("event.tool == 'bash'".to_owned())).unwrap()); +} + +#[test] +fn json_array_input_converts() { + let mut input = HashMap::new(); + input.insert("items".to_owned(), json!(["a", "b"])); + let ev = HookEvent { + event: "tool:before".to_owned(), + tool: "bash".to_owned(), + input, + output: HashMap::new(), + session_id: "s".to_owned(), + caller: "test".to_owned(), + }; + assert!(matches_event(&ev, &Some("event.tool == 'bash'".to_owned())).unwrap()); +} + +#[test] +fn json_nested_object_converts() { + let mut input = HashMap::new(); + input.insert("meta".to_owned(), json!({"key": "value"})); + let ev = HookEvent { + event: "tool:before".to_owned(), + tool: "bash".to_owned(), + input, + output: HashMap::new(), + session_id: "s".to_owned(), + caller: "test".to_owned(), + }; + assert!(matches_event(&ev, &Some("event.tool == 'bash'".to_owned())).unwrap()); +} + +#[test] +fn output_map_is_accessible() { + let mut output = HashMap::new(); + output.insert("exit_code".to_owned(), json!(0)); + let ev = HookEvent { + event: "tool:after".to_owned(), + tool: "bash".to_owned(), + input: HashMap::new(), + output, + session_id: "s".to_owned(), + caller: "test".to_owned(), + }; + assert!(matches_event(&ev, &Some("event.tool == 'bash'".to_owned())).unwrap()); +} + +#[test] +fn non_bool_truthy_result_returns_true() { + // CEL expression returning a string (non-bool, non-null) → truthy + let ev = event_with_cmd("ls"); + assert!(matches_event(&ev, &Some("event.tool".to_owned())).unwrap()); +} + +#[test] +fn null_result_returns_false() { + // CEL `null` literal returns Value::Null → falsy + let ev = event_with_cmd("ls"); + assert!(!matches_event(&ev, &Some("null".to_owned())).unwrap()); +} + +#[test] +fn command_words_matches_push_subcommand() { + let ev = event_with_cmd("git -C /some/path push origin main"); + assert!(matches_event( + &ev, + &Some("input.command_words.exists(x, x == 'push')".to_owned()) + ) + .unwrap()); +} + +#[test] +fn command_words_does_not_match_path_containing_push() { + let ev = event_with_cmd("git add .steplock/checklists/pre-push/config.toml"); + assert!(!matches_event( + &ev, + &Some("input.command_words.exists(x, x == 'push')".to_owned()) + ) + .unwrap()); +} + +#[test] +fn command_words_matches_commit_with_dash_c_flag() { + let ev = event_with_cmd("git -C /repo commit -m \"fix bug\""); + assert!(matches_event( + &ev, + &Some("input.command_words.exists(x, x == 'commit')".to_owned()) + ) + .unwrap()); +} + +#[test] +fn command_words_does_not_match_path_containing_commit() { + let ev = event_with_cmd("git add .steplock/checklists/pre-commit/config.toml"); + assert!(!matches_event( + &ev, + &Some("input.command_words.exists(x, x == 'commit')".to_owned()) + ) + .unwrap()); +} + +// Regression: `command_words` includes message text, so "push" in a commit +// body must not trigger the pre-push gate. The guard is to also require +// that "commit" is absent (real git push commands never have that word). +#[test] +fn pre_push_expr_does_not_trigger_on_commit_with_push_in_message() { + let ev = event_with_cmd("git -C /repo commit -m \"add CI push path filter\""); + let safe_expr = "input.command_words.exists(x, x == 'push') && !input.command_words.exists(x, x == 'commit')"; + assert!(!matches_event(&ev, &Some(safe_expr.to_owned())).unwrap()); +} + +#[test] +fn pre_push_expr_still_matches_git_push() { + let ev = event_with_cmd("git -C /repo push origin main"); + let safe_expr = "input.command_words.exists(x, x == 'push') && !input.command_words.exists(x, x == 'commit')"; + assert!(matches_event(&ev, &Some(safe_expr.to_owned())).unwrap()); +} + +// Regression: `command_words` includes flags and body text passed to other +// commands (e.g. `gh pr create --body "...commit..."`) — the safe guard is +// to also require that "git" is present so non-git tools don't match. +#[test] +fn pre_commit_expr_does_not_trigger_on_gh_pr_create_with_commit_in_body() { + let ev = event_with_cmd("gh pr create --body \"A commit was merged\""); + let safe_expr = + "input.command_words.exists(x, x == 'commit') && input.command_words.exists(x, x == 'git')"; + assert!(!matches_event(&ev, &Some(safe_expr.to_owned())).unwrap()); +} + +#[test] +fn pre_commit_expr_still_matches_git_commit() { + let ev = event_with_cmd("git -C /repo commit -m \"fix bug\""); + let safe_expr = + "input.command_words.exists(x, x == 'commit') && input.command_words.exists(x, x == 'git')"; + assert!(matches_event(&ev, &Some(safe_expr.to_owned())).unwrap()); +} diff --git a/core/src/config.rs b/core/src/config.rs index e69597e..bd661ab 100644 --- a/core/src/config.rs +++ b/core/src/config.rs @@ -50,66 +50,5 @@ pub fn parse_config(path: &str, content: &str) -> crate::Result #[cfg(test)] #[allow(clippy::unwrap_used)] -mod tests { - use super::*; - - #[test] - fn parses_minimal_config() { - let cfg = parse_config( - "test.toml", - r#" -on_event = "tool:before" -on_tool = "bash" -"#, - ) - .unwrap(); - assert_eq!(cfg.on_event, "tool:before"); - assert_eq!(cfg.on_tool, "bash"); - assert!(cfg.match_input.is_none()); - assert!(matches!(cfg.reset, Reset::Session)); - assert!(!cfg.allow_preview_request); - } - - #[test] - fn on_tool_defaults_to_empty_when_omitted() { - let cfg = parse_config( - "test.toml", - r#" -on_event = "tool:before" -"#, - ) - .unwrap(); - assert_eq!(cfg.on_tool, ""); - } - - #[test] - fn parses_full_config() { - let cfg = parse_config( - "test.toml", - r#" -on_event = "tool:after" -on_tool = "write_file" -match_input = "input.path.startsWith('/etc')" -reset = "always" -allow_preview_request = true -"#, - ) - .unwrap(); - assert_eq!(cfg.on_event, "tool:after"); - assert_eq!(cfg.on_tool, "write_file"); - assert_eq!(cfg.match_input.unwrap(), "input.path.startsWith('/etc')"); - assert!(matches!(cfg.reset, Reset::Always)); - assert!(cfg.allow_preview_request); - } - - #[test] - fn error_on_invalid_toml() { - let err = parse_config("bad.toml", "not valid toml !!!@@@"); - err.unwrap_err(); - } - - #[test] - fn reset_default_is_session() { - assert!(matches!(Reset::default(), Reset::Session)); - } -} +#[path = "config_tests.rs"] +mod tests; diff --git a/core/src/config_tests.rs b/core/src/config_tests.rs new file mode 100644 index 0000000..dc28078 --- /dev/null +++ b/core/src/config_tests.rs @@ -0,0 +1,62 @@ +//! Unit tests for `config`. +use super::*; + +#[test] +fn parses_minimal_config() { + let cfg = parse_config( + "test.toml", + r#" +on_event = "tool:before" +on_tool = "bash" +"#, + ) + .unwrap(); + assert_eq!(cfg.on_event, "tool:before"); + assert_eq!(cfg.on_tool, "bash"); + assert!(cfg.match_input.is_none()); + assert!(matches!(cfg.reset, Reset::Session)); + assert!(!cfg.allow_preview_request); +} + +#[test] +fn on_tool_defaults_to_empty_when_omitted() { + let cfg = parse_config( + "test.toml", + r#" +on_event = "tool:before" +"#, + ) + .unwrap(); + assert_eq!(cfg.on_tool, ""); +} + +#[test] +fn parses_full_config() { + let cfg = parse_config( + "test.toml", + r#" +on_event = "tool:after" +on_tool = "write_file" +match_input = "input.path.startsWith('/etc')" +reset = "always" +allow_preview_request = true +"#, + ) + .unwrap(); + assert_eq!(cfg.on_event, "tool:after"); + assert_eq!(cfg.on_tool, "write_file"); + assert_eq!(cfg.match_input.unwrap(), "input.path.startsWith('/etc')"); + assert!(matches!(cfg.reset, Reset::Always)); + assert!(cfg.allow_preview_request); +} + +#[test] +fn error_on_invalid_toml() { + let err = parse_config("bad.toml", "not valid toml !!!@@@"); + err.unwrap_err(); +} + +#[test] +fn reset_default_is_session() { + assert!(matches!(Reset::default(), Reset::Session)); +} diff --git a/core/src/flow.rs b/core/src/flow.rs index c5e730a..fef04bf 100644 --- a/core/src/flow.rs +++ b/core/src/flow.rs @@ -193,246 +193,10 @@ fn topo_order(initial: &[String], transitions: &HashMap>) -> #[cfg(test)] #[allow(clippy::indexing_slicing, clippy::unwrap_used)] -mod tests { - use super::*; - - const SIMPLE_MMD: &str = r" -stateDiagram-v2 - [*] --> clean_code - clean_code --> test_coverage - test_coverage --> documentation - documentation --> no_secrets - no_secrets --> [*] - - clean_code : Did you write clean, readable code? - test_coverage: Did you increase test coverage by at least a little? - documentation: Did you update relevant documentation? - no_secrets : Did you check for hardcoded secrets or credentials? -"; - - #[test] - fn parses_simple_flow() { - let g = parse_mmd("test.mmd", SIMPLE_MMD).unwrap(); - assert_eq!(g.initial, vec!["clean_code"]); - assert_eq!( - g.order, - vec!["clean_code", "test_coverage", "documentation", "no_secrets"] - ); - assert!(g.is_terminal("no_secrets")); - assert_eq!(g.next_states("clean_code"), vec!["test_coverage"]); - assert_eq!( - g.labels["clean_code"], - "Did you write clean, readable code?" - ); - } - - #[test] - fn parses_branching_flow() { - let mmd = r" -stateDiagram-v2 - [*] --> clean_code - clean_code --> test_coverage - clean_code --> skip_reason - test_coverage --> [*] - skip_reason --> [*] - clean_code : Did you write clean, readable code? - test_coverage : Did you increase test coverage? - skip_reason : Describe why test coverage was skipped. -"; - let g = parse_mmd("test.mmd", mmd).unwrap(); - let mut nexts = g.next_states("clean_code"); - nexts.sort(); - assert_eq!(nexts, vec!["skip_reason", "test_coverage"]); - assert!(g.is_terminal("test_coverage")); - assert!(g.is_terminal("skip_reason")); - } - - #[test] - fn error_on_missing_initial_state() { - let mmd = "stateDiagram-v2\n a --> b\n"; - let err = parse_mmd("test.mmd", mmd); - assert!(err.is_err()); - assert!(err.unwrap_err().to_string().contains("no [*]")); - } - - #[test] - fn ignores_direction_and_comments() { - let mmd = r"stateDiagram-v2 - direction LR - %% this is a comment - [*] --> step - step --> [*] - step : Do it -"; - let g = parse_mmd("test.mmd", mmd).unwrap(); - assert_eq!(g.initial, vec!["step"]); - assert_eq!(g.labels["step"], "Do it"); - } - - #[test] - fn pending_after_returns_unvisited() { - let g = parse_mmd("test.mmd", SIMPLE_MMD).unwrap(); - let pending = g.pending_after(&["clean_code".to_owned(), "test_coverage".to_owned()]); - assert_eq!(pending, vec!["documentation", "no_secrets"]); - } - - #[test] - fn pending_after_all_visited_is_empty() { - let g = parse_mmd("test.mmd", SIMPLE_MMD).unwrap(); - let all: Vec = g.order.clone(); - assert!(g.pending_after(&all).is_empty()); - } - - #[test] - fn next_states_for_terminal_excludes_pseudo() { - let g = parse_mmd("test.mmd", SIMPLE_MMD).unwrap(); - // no_secrets is terminal; next_states should be empty (excludes [*]) - assert!(g.next_states("no_secrets").is_empty()); - } - - #[test] - fn next_states_for_unknown_state_is_empty() { - let g = parse_mmd("test.mmd", SIMPLE_MMD).unwrap(); - assert!(g.next_states("nonexistent").is_empty()); - } - - #[test] - fn is_terminal_false_for_non_terminal() { - let g = parse_mmd("test.mmd", SIMPLE_MMD).unwrap(); - assert!(!g.is_terminal("clean_code")); - } - - #[test] - fn duplicate_initial_not_added_twice() { - // Two [*] --> same_state transitions should not produce duplicates - let mmd = "stateDiagram-v2\n [*] --> s\n [*] --> s\n s --> [*]\n s : Step\n"; - let g = parse_mmd("test.mmd", mmd).unwrap(); - assert_eq!(g.initial.len(), 1); - } - - #[test] - fn ignores_unlabeled_bare_state_lines() { - // A line with no --> and no : is silently ignored - let mmd = "stateDiagram-v2\n [*] --> s\n s --> [*]\n s : Step\n orphan_note\n"; - let g = parse_mmd("test.mmd", mmd).unwrap(); - assert_eq!(g.order, vec!["s"]); - } - - #[test] - fn state_with_no_outgoing_transitions_in_order() { - // State appears in order but has no transitions entry (only a destination, no label/source) - let mmd = "stateDiagram-v2\n [*] --> a\n a --> b\n b --> c\n c --> [*]\n a : Step A\n b : Step B\n c : Step C\n"; - let g = parse_mmd("test.mmd", mmd).unwrap(); - // 'c' has a transition to [*] — still appears - assert!(g.order.contains(&"c".to_owned())); - } - - #[test] - fn state_with_no_outgoing_transitions_included_in_order() { - // "leaf" is a terminal state (only → [*]), so transitions.get("leaf") - // returns only the pseudo-entry for [*], and next_states("leaf") is empty. - let mmd = "stateDiagram-v2\n [*] --> root\n root --> leaf\n leaf --> [*]\n root : Root\n leaf : Leaf\n"; - let g = parse_mmd("test.mmd", mmd).unwrap(); - assert!(g.order.contains(&"leaf".to_owned())); - assert!(g.next_states("leaf").is_empty()); - } - - #[test] - fn error_on_cycle_with_no_exit() { - // step_a and step_b form a cycle; neither reaches [*] - let mmd = - "stateDiagram-v2\n [*] --> step_a\n step_a --> step_b\n step_b --> step_a\n"; - let err = parse_mmd("test.mmd", mmd).unwrap_err(); - let msg = err.to_string(); - assert!(msg.contains("no path to [*]"), "got: {msg}"); - } - - #[test] - fn error_on_dead_end_state() { - // step_b has no outgoing transition — it can never reach [*] - let mmd = - "stateDiagram-v2\n [*] --> step_a\n step_a --> step_b\n step_a --> [*]\n"; - let err = parse_mmd("test.mmd", mmd).unwrap_err(); - let msg = err.to_string(); - assert!(msg.contains("no path to [*]"), "got: {msg}"); - } - - #[test] - fn cycle_with_exit_is_valid() { - // step_a has both a cycle to step_b AND a path to [*] — valid - let mmd = "stateDiagram-v2\n [*] --> step_a\n step_a --> step_b\n step_b --> step_a\n step_a --> [*]\n"; - // step_b still has no path to [*] (only step_a does, and step_b can reach step_a) - // Actually step_b → step_a → [*] IS a path, so this should be valid. - // step_b can reach [*] via step_b → step_a → [*] - let g = parse_mmd("test.mmd", mmd).unwrap(); - assert!(g.terminal.contains("step_a")); - } - - #[test] - fn topo_order_deduplicates_via_visited() { - // State referenced from multiple predecessors only appears once in order - let mmd = "stateDiagram-v2\n [*] --> a\n [*] --> b\n a --> c\n b --> c\n c --> [*]\n a:A\n b:B\n c:C\n"; - let g = parse_mmd("test.mmd", mmd).unwrap(); - let count = g.order.iter().filter(|s| s.as_str() == "c").count(); - assert_eq!(count, 1); - } -} +#[path = "flow_tests.rs"] +mod tests; #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] -mod proptest_tests { - use proptest::prelude::*; - - use super::*; - - fn make_single_step_mmd(state: &str) -> String { - format!("stateDiagram-v2\n [*] --> {state}\n {state} --> [*]\n {state} : A step\n") - } - - proptest! { - /// A syntactically valid single-step diagram always parses successfully. - #[test] - fn valid_single_step_always_parses(state in "[a-z][a-z0-9_]{0,15}") { - let mmd = make_single_step_mmd(&state); - let g = parse_mmd("prop.mmd", &mmd).expect("valid diagram must parse"); - prop_assert_eq!(g.initial, vec![state.clone()]); - prop_assert_eq!(g.order, vec![state]); - } - - /// Every state in `initial` also appears in `order`. - #[test] - fn initial_states_in_order(state in "[a-z][a-z0-9_]{0,15}") { - let mmd = make_single_step_mmd(&state); - if let Ok(g) = parse_mmd("prop.mmd", &mmd) { - for s in &g.initial { - prop_assert!(g.order.contains(s)); - } - } - } - - /// `pending_after([])` equals `order` for any valid diagram. - #[test] - fn pending_after_empty_equals_order(state in "[a-z][a-z0-9_]{0,15}") { - let mmd = make_single_step_mmd(&state); - if let Ok(g) = parse_mmd("prop.mmd", &mmd) { - prop_assert_eq!(g.pending_after(&[]), g.order); - } - } - - /// `pending_after(order)` is always empty. - #[test] - fn pending_after_all_is_empty(state in "[a-z][a-z0-9_]{0,15}") { - let mmd = make_single_step_mmd(&state); - if let Ok(g) = parse_mmd("prop.mmd", &mmd) { - let all = g.order.clone(); - prop_assert!(g.pending_after(&all).is_empty()); - } - } - - /// Arbitrary strings never panic — `parse_mmd` always returns `Ok` or `Err`. - #[test] - fn parse_never_panics(input in any::()) { - let _: crate::Result<_> = parse_mmd("fuzz.mmd", &input); - } - } -} +#[path = "flow_proptest_tests.rs"] +mod proptest_tests; diff --git a/core/src/flow_proptest_tests.rs b/core/src/flow_proptest_tests.rs new file mode 100644 index 0000000..ecfa505 --- /dev/null +++ b/core/src/flow_proptest_tests.rs @@ -0,0 +1,55 @@ +//! Property-based tests for `flow`. +use proptest::prelude::*; + +use super::*; + +fn make_single_step_mmd(state: &str) -> String { + format!("stateDiagram-v2\n [*] --> {state}\n {state} --> [*]\n {state} : A step\n") +} + +proptest! { + /// A syntactically valid single-step diagram always parses successfully. + #[test] + fn valid_single_step_always_parses(state in "[a-z][a-z0-9_]{0,15}") { + let mmd = make_single_step_mmd(&state); + let g = parse_mmd("prop.mmd", &mmd).expect("valid diagram must parse"); + prop_assert_eq!(g.initial, vec![state.clone()]); + prop_assert_eq!(g.order, vec![state]); + } + + /// Every state in `initial` also appears in `order`. + #[test] + fn initial_states_in_order(state in "[a-z][a-z0-9_]{0,15}") { + let mmd = make_single_step_mmd(&state); + if let Ok(g) = parse_mmd("prop.mmd", &mmd) { + for s in &g.initial { + prop_assert!(g.order.contains(s)); + } + } + } + + /// `pending_after([])` equals `order` for any valid diagram. + #[test] + fn pending_after_empty_equals_order(state in "[a-z][a-z0-9_]{0,15}") { + let mmd = make_single_step_mmd(&state); + if let Ok(g) = parse_mmd("prop.mmd", &mmd) { + prop_assert_eq!(g.pending_after(&[]), g.order); + } + } + + /// `pending_after(order)` is always empty. + #[test] + fn pending_after_all_is_empty(state in "[a-z][a-z0-9_]{0,15}") { + let mmd = make_single_step_mmd(&state); + if let Ok(g) = parse_mmd("prop.mmd", &mmd) { + let all = g.order.clone(); + prop_assert!(g.pending_after(&all).is_empty()); + } + } + + /// Arbitrary strings never panic — `parse_mmd` always returns `Ok` or `Err`. + #[test] + fn parse_never_panics(input in any::()) { + let _: crate::Result<_> = parse_mmd("fuzz.mmd", &input); + } +} diff --git a/core/src/flow_tests.rs b/core/src/flow_tests.rs new file mode 100644 index 0000000..ce02b93 --- /dev/null +++ b/core/src/flow_tests.rs @@ -0,0 +1,181 @@ +//! Unit tests for `flow`. +use super::*; + +const SIMPLE_MMD: &str = r" +stateDiagram-v2 + [*] --> clean_code + clean_code --> test_coverage + test_coverage --> documentation + documentation --> no_secrets + no_secrets --> [*] + + clean_code : Did you write clean, readable code? + test_coverage: Did you increase test coverage by at least a little? + documentation: Did you update relevant documentation? + no_secrets : Did you check for hardcoded secrets or credentials? +"; + +#[test] +fn parses_simple_flow() { + let g = parse_mmd("test.mmd", SIMPLE_MMD).unwrap(); + assert_eq!(g.initial, vec!["clean_code"]); + assert_eq!( + g.order, + vec!["clean_code", "test_coverage", "documentation", "no_secrets"] + ); + assert!(g.is_terminal("no_secrets")); + assert_eq!(g.next_states("clean_code"), vec!["test_coverage"]); + assert_eq!( + g.labels["clean_code"], + "Did you write clean, readable code?" + ); +} + +#[test] +fn parses_branching_flow() { + let mmd = r" +stateDiagram-v2 + [*] --> clean_code + clean_code --> test_coverage + clean_code --> skip_reason + test_coverage --> [*] + skip_reason --> [*] + clean_code : Did you write clean, readable code? + test_coverage : Did you increase test coverage? + skip_reason : Describe why test coverage was skipped. +"; + let g = parse_mmd("test.mmd", mmd).unwrap(); + let mut nexts = g.next_states("clean_code"); + nexts.sort(); + assert_eq!(nexts, vec!["skip_reason", "test_coverage"]); + assert!(g.is_terminal("test_coverage")); + assert!(g.is_terminal("skip_reason")); +} + +#[test] +fn error_on_missing_initial_state() { + let mmd = "stateDiagram-v2\n a --> b\n"; + let err = parse_mmd("test.mmd", mmd); + assert!(err.is_err()); + assert!(err.unwrap_err().to_string().contains("no [*]")); +} + +#[test] +fn ignores_direction_and_comments() { + let mmd = r"stateDiagram-v2 + direction LR + %% this is a comment + [*] --> step + step --> [*] + step : Do it +"; + let g = parse_mmd("test.mmd", mmd).unwrap(); + assert_eq!(g.initial, vec!["step"]); + assert_eq!(g.labels["step"], "Do it"); +} + +#[test] +fn pending_after_returns_unvisited() { + let g = parse_mmd("test.mmd", SIMPLE_MMD).unwrap(); + let pending = g.pending_after(&["clean_code".to_owned(), "test_coverage".to_owned()]); + assert_eq!(pending, vec!["documentation", "no_secrets"]); +} + +#[test] +fn pending_after_all_visited_is_empty() { + let g = parse_mmd("test.mmd", SIMPLE_MMD).unwrap(); + let all: Vec = g.order.clone(); + assert!(g.pending_after(&all).is_empty()); +} + +#[test] +fn next_states_for_terminal_excludes_pseudo() { + let g = parse_mmd("test.mmd", SIMPLE_MMD).unwrap(); + // no_secrets is terminal; next_states should be empty (excludes [*]) + assert!(g.next_states("no_secrets").is_empty()); +} + +#[test] +fn next_states_for_unknown_state_is_empty() { + let g = parse_mmd("test.mmd", SIMPLE_MMD).unwrap(); + assert!(g.next_states("nonexistent").is_empty()); +} + +#[test] +fn is_terminal_false_for_non_terminal() { + let g = parse_mmd("test.mmd", SIMPLE_MMD).unwrap(); + assert!(!g.is_terminal("clean_code")); +} + +#[test] +fn duplicate_initial_not_added_twice() { + // Two [*] --> same_state transitions should not produce duplicates + let mmd = "stateDiagram-v2\n [*] --> s\n [*] --> s\n s --> [*]\n s : Step\n"; + let g = parse_mmd("test.mmd", mmd).unwrap(); + assert_eq!(g.initial.len(), 1); +} + +#[test] +fn ignores_unlabeled_bare_state_lines() { + // A line with no --> and no : is silently ignored + let mmd = "stateDiagram-v2\n [*] --> s\n s --> [*]\n s : Step\n orphan_note\n"; + let g = parse_mmd("test.mmd", mmd).unwrap(); + assert_eq!(g.order, vec!["s"]); +} + +#[test] +fn state_with_no_outgoing_transitions_in_order() { + // State appears in order but has no transitions entry (only a destination, no label/source) + let mmd = "stateDiagram-v2\n [*] --> a\n a --> b\n b --> c\n c --> [*]\n a : Step A\n b : Step B\n c : Step C\n"; + let g = parse_mmd("test.mmd", mmd).unwrap(); + // 'c' has a transition to [*] — still appears + assert!(g.order.contains(&"c".to_owned())); +} + +#[test] +fn state_with_no_outgoing_transitions_included_in_order() { + // "leaf" is a terminal state (only → [*]), so transitions.get("leaf") + // returns only the pseudo-entry for [*], and next_states("leaf") is empty. + let mmd = "stateDiagram-v2\n [*] --> root\n root --> leaf\n leaf --> [*]\n root : Root\n leaf : Leaf\n"; + let g = parse_mmd("test.mmd", mmd).unwrap(); + assert!(g.order.contains(&"leaf".to_owned())); + assert!(g.next_states("leaf").is_empty()); +} + +#[test] +fn error_on_cycle_with_no_exit() { + // step_a and step_b form a cycle; neither reaches [*] + let mmd = "stateDiagram-v2\n [*] --> step_a\n step_a --> step_b\n step_b --> step_a\n"; + let err = parse_mmd("test.mmd", mmd).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("no path to [*]"), "got: {msg}"); +} + +#[test] +fn error_on_dead_end_state() { + // step_b has no outgoing transition — it can never reach [*] + let mmd = "stateDiagram-v2\n [*] --> step_a\n step_a --> step_b\n step_a --> [*]\n"; + let err = parse_mmd("test.mmd", mmd).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("no path to [*]"), "got: {msg}"); +} + +#[test] +fn cycle_with_exit_is_valid() { + // step_a has both a cycle to step_b AND a path to [*] — valid + let mmd = "stateDiagram-v2\n [*] --> step_a\n step_a --> step_b\n step_b --> step_a\n step_a --> [*]\n"; + // step_b still has no path to [*] (only step_a does, and step_b can reach step_a) + // Actually step_b → step_a → [*] IS a path, so this should be valid. + // step_b can reach [*] via step_b → step_a → [*] + let g = parse_mmd("test.mmd", mmd).unwrap(); + assert!(g.terminal.contains("step_a")); +} + +#[test] +fn topo_order_deduplicates_via_visited() { + // State referenced from multiple predecessors only appears once in order + let mmd = "stateDiagram-v2\n [*] --> a\n [*] --> b\n a --> c\n b --> c\n c --> [*]\n a:A\n b:B\n c:C\n"; + let g = parse_mmd("test.mmd", mmd).unwrap(); + let count = g.order.iter().filter(|s| s.as_str() == "c").count(); + assert_eq!(count, 1); +} diff --git a/core/src/global_config.rs b/core/src/global_config.rs index ccc4aff..228db0e 100644 --- a/core/src/global_config.rs +++ b/core/src/global_config.rs @@ -46,87 +46,5 @@ fn resolve_global_dir( } #[cfg(test)] -mod tests { - use super::*; - use std::collections::HashMap; - - /// 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(), 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, abs("custom").into()), - ("XDG_CONFIG_HOME", abs("xdg").into()), - ]), - Some(abs("home")), - ); - assert_eq!( - dir, - 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, 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())]), - 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())]), - Some(abs("home")), - ); - assert_eq!( - dir, - Some(home_config("home")), - "relative XDG_CONFIG_HOME must fall back to the home directory" - ); - } - - #[test] - 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(&[]), None), - None, - "no env and no home means no global dir" - ); - } -} +#[path = "global_config_tests.rs"] +mod tests; diff --git a/core/src/global_config_tests.rs b/core/src/global_config_tests.rs new file mode 100644 index 0000000..f50c8b5 --- /dev/null +++ b/core/src/global_config_tests.rs @@ -0,0 +1,83 @@ +//! Unit tests for `global_config`. +use super::*; +use std::collections::HashMap; + +/// 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(), 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, abs("custom").into()), + ("XDG_CONFIG_HOME", abs("xdg").into()), + ]), + Some(abs("home")), + ); + assert_eq!( + dir, + 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, 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())]), + 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())]), + Some(abs("home")), + ); + assert_eq!( + dir, + Some(home_config("home")), + "relative XDG_CONFIG_HOME must fall back to the home directory" + ); +} + +#[test] +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(&[]), None), + None, + "no env and no home means no global dir" + ); +} diff --git a/core/src/run.rs b/core/src/run.rs index 5c7382d..08806bc 100644 --- a/core/src/run.rs +++ b/core/src/run.rs @@ -107,691 +107,5 @@ fn cleanup_session(steplock_dir: &Path, session_id: &str) -> Result<()> { #[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; - - fn make_event(event: &str, tool: &str, cmd: &str, session: &str) -> HookEvent { - let mut input = HashMap::new(); - input.insert( - "command".to_owned(), - serde_json::Value::String(cmd.to_owned()), - ); - HookEvent { - event: event.to_owned(), - tool: tool.to_owned(), - input, - output: HashMap::new(), - session_id: session.to_owned(), - caller: "claude-code".to_owned(), - } - } - - fn setup_checklist(root: &Path) { - let cl_dir = root.join(".steplock/checklists/quality-gate"); - fs::create_dir_all(&cl_dir).unwrap(); - - fs::write( - cl_dir.join("config.toml"), - r#"on_event = "tool:before" -on_tool = "bash" -match_input = "input.command.contains('git push')" -reset = "session" -"#, - ) - .unwrap(); - - fs::write( - cl_dir.join("flow.mmd"), - r"stateDiagram-v2 - [*] --> clean_code - clean_code --> [*] - clean_code: Did you write clean code? -", - ) - .unwrap(); - } - - #[test] - fn approves_non_matching_event() { - let tmp = TempDir::new().unwrap(); - setup_checklist(tmp.path()); - let event = make_event("tool:before", "bash", "ls -la", "sess-1"); - let resp = run(&event, tmp.path()).unwrap(); - assert!(matches!(resp, HookResponse::Approve)); - } - - #[test] - fn blocks_on_matching_event() { - let tmp = TempDir::new().unwrap(); - setup_checklist(tmp.path()); - let event = make_event("tool:before", "bash", "git push origin main", "sess-1"); - let resp = run(&event, tmp.path()).unwrap(); - assert!(matches!(resp, HookResponse::Block { .. })); - } - - #[test] - fn approves_and_resets_state_when_complete() { - let tmp = TempDir::new().unwrap(); - setup_checklist(tmp.path()); - - // State at [*] = checklist complete from a prior ack sequence - let session_dir = tmp.path().join(".steplock/sessions/sess-1/quality-gate"); - fs::create_dir_all(&session_dir).unwrap(); - let state = SessionState { - checklist: "quality-gate".to_owned(), - current_state: "[*]".to_owned(), - next_state: None, - transitions: vec![], - visited: vec!["clean_code".to_owned()], - }; - save_state(&session_dir.join("state.json"), &state).unwrap(); - - // This attempt is approved (checklist was already satisfied) - let event = make_event("tool:before", "bash", "git push origin main", "sess-1"); - let resp = run(&event, tmp.path()).unwrap(); - assert!(matches!(resp, HookResponse::Approve)); - - // State is now reset so the NEXT attempt starts fresh - let next_state = load_state(&session_dir.join("state.json")).unwrap(); - assert_eq!(next_state.current_state, "clean_code"); - assert!(next_state.visited.is_empty()); - } - - #[test] - fn approves_when_no_checklists_dir() { - let tmp = TempDir::new().unwrap(); - let event = make_event("tool:before", "bash", "git push origin main", "sess-1"); - let resp = run(&event, tmp.path()).unwrap(); - assert!(matches!(resp, HookResponse::Approve)); - } - - #[test] - fn approves_on_event_type_mismatch() { - let tmp = TempDir::new().unwrap(); - setup_checklist(tmp.path()); - let event = make_event("tool:after", "bash", "git push origin main", "sess-1"); - let resp = run(&event, tmp.path()).unwrap(); - assert!(matches!(resp, HookResponse::Approve)); - } - - #[test] - fn approves_on_tool_mismatch() { - let tmp = TempDir::new().unwrap(); - setup_checklist(tmp.path()); - let event = make_event( - "tool:before", - "write_file", - "git push origin main", - "sess-1", - ); - let resp = run(&event, tmp.path()).unwrap(); - assert!(matches!(resp, HookResponse::Approve)); - } - - #[test] - fn skips_checklist_dir_missing_files() { - let tmp = TempDir::new().unwrap(); - // Create dir but no config.toml / flow.mmd - let cl_dir = tmp.path().join(".steplock/checklists/empty-gate"); - fs::create_dir_all(&cl_dir).unwrap(); - let event = make_event("tool:before", "bash", "git push origin main", "sess-1"); - let resp = run(&event, tmp.path()).unwrap(); - assert!(matches!(resp, HookResponse::Approve)); - } - - #[test] - fn reset_always_blocks_every_time() { - let tmp = TempDir::new().unwrap(); - let cl_dir = tmp.path().join(".steplock/checklists/always-gate"); - fs::create_dir_all(&cl_dir).unwrap(); - fs::write( - cl_dir.join("config.toml"), - r#"on_event = "tool:before" -on_tool = "bash" -match_input = "input.command.contains('git push')" -reset = "always" -"#, - ) - .unwrap(); - fs::write( - cl_dir.join("flow.mmd"), - r"stateDiagram-v2 - [*] --> check - check --> [*] - check: Did you check? -", - ) - .unwrap(); - - let event = make_event("tool:before", "bash", "git push origin main", "sess-x"); - let resp = run(&event, tmp.path()).unwrap(); - assert!(matches!(resp, HookResponse::Block { .. })); - - // Second invocation still blocks (no state persistence) - let resp2 = run(&event, tmp.path()).unwrap(); - assert!(matches!(resp2, HookResponse::Block { .. })); - } - - #[test] - fn block_message_contains_label() { - let tmp = TempDir::new().unwrap(); - setup_checklist(tmp.path()); - let event = make_event("tool:before", "bash", "git push origin main", "sess-1"); - let resp = run(&event, tmp.path()).unwrap(); - if let HookResponse::Block { message } = resp { - assert!(message.contains("Did you write clean code?")); - } else { - panic!("expected block"); - } - } - - #[test] - fn block_message_contains_checklist_name() { - let tmp = TempDir::new().unwrap(); - setup_checklist(tmp.path()); - let event = make_event("tool:before", "bash", "git push origin main", "sess-name"); - let resp = run(&event, tmp.path()).unwrap(); - if let HookResponse::Block { message } = resp { - // The checklist dir is "quality-gate" — it must appear in the message prefix - assert!( - message.starts_with("[quality-gate:"), - "expected [quality-gate: prefix, got: {message}" - ); - } else { - panic!("expected block"); - } - } - - #[test] - fn block_message_contains_ack_sh_path() { - let tmp = TempDir::new().unwrap(); - setup_checklist(tmp.path()); - let event = make_event("tool:before", "bash", "git push origin main", "sess-1"); - let resp = run(&event, tmp.path()).unwrap(); - if let HookResponse::Block { message } = resp { - assert!(message.contains("ack.sh")); - } else { - panic!("expected block"); - } - } - - #[test] - fn block_message_shows_step_progress() { - let tmp = TempDir::new().unwrap(); - // 3-step linear flow: a → b → c → [*] - let cl_dir = tmp.path().join(".steplock/checklists/progress-gate"); - fs::create_dir_all(&cl_dir).unwrap(); - fs::write( - cl_dir.join("config.toml"), - "on_event = \"tool:before\"\non_tool = \"bash\"\nmatch_input = \"input.command.contains('git push')\"\nreset = \"session\"\n", - ) - .unwrap(); - fs::write( - cl_dir.join("flow.mmd"), - "stateDiagram-v2\n [*] --> a\n a --> b\n b --> c\n c --> [*]\n a: Step A\n b: Step B\n c: Step C\n", - ) - .unwrap(); - - // First block: step 1/3 - let event = make_event("tool:before", "bash", "git push", "sess-prog"); - let resp = run(&event, tmp.path()).unwrap(); - if let HookResponse::Block { message } = resp { - assert!( - message.contains("1/3"), - "expected 1/3 in message, got: {message}" - ); - } else { - panic!("expected block"); - } - - // Advance state manually to simulate ack - let state_path = tmp - .path() - .join(".steplock/sessions/sess-prog/progress-gate/state.json"); - let mut state = load_state(&state_path).unwrap(); - state.visited.push(state.current_state.clone()); - state.current_state = "b".to_owned(); - state.next_state = Some("c".to_owned()); - state.transitions = vec!["c".to_owned()]; - save_state(&state_path, &state).unwrap(); - - // Second block: step 2/3 - let resp2 = run(&event, tmp.path()).unwrap(); - if let HookResponse::Block { message } = resp2 { - assert!( - message.contains("2/3"), - "expected 2/3 in message, got: {message}" - ); - } else { - panic!("expected block"); - } - } - - #[test] - fn fallback_session_id_generated_when_empty() { - let tmp = TempDir::new().unwrap(); - setup_checklist(tmp.path()); - // Empty session_id triggers fallback UUID generation - let mut input = HashMap::new(); - input.insert( - "command".to_owned(), - serde_json::Value::String("git push".to_owned()), - ); - let event = HookEvent { - event: "tool:before".to_owned(), - tool: "bash".to_owned(), - input, - output: HashMap::new(), - session_id: String::new(), - caller: "unknown".to_owned(), - }; - let resp = run(&event, tmp.path()).unwrap(); - assert!(matches!(resp, HookResponse::Block { .. })); - // fallback-id file created - assert!(tmp.path().join(".steplock/sessions/fallback-id").exists()); - - // Second invocation reuses the same fallback ID - let resp2 = run(&event, tmp.path()).unwrap(); - assert!(matches!(resp2, HookResponse::Block { .. })); - } - - #[test] - fn allow_preview_request_adds_tip() { - let tmp = TempDir::new().unwrap(); - let cl_dir = tmp.path().join(".steplock/checklists/preview-gate"); - fs::create_dir_all(&cl_dir).unwrap(); - fs::write( - cl_dir.join("config.toml"), - r#"on_event = "tool:before" -on_tool = "bash" -match_input = "input.command.contains('git push')" -reset = "session" -allow_preview_request = true -"#, - ) - .unwrap(); - fs::write( - cl_dir.join("flow.mmd"), - r"stateDiagram-v2 - [*] --> check - check --> [*] - check: Did you check? -", - ) - .unwrap(); - - let event = make_event( - "tool:before", - "bash", - "git push origin main", - "sess-preview", - ); - let resp = run(&event, tmp.path()).unwrap(); - if let HookResponse::Block { message } = resp { - assert!(message.contains("preview.sh")); - } else { - panic!("expected block"); - } - } - - #[test] - fn branching_flow_block_message_lists_options() { - let tmp = TempDir::new().unwrap(); - let cl_dir = tmp.path().join(".steplock/checklists/branch-gate"); - fs::create_dir_all(&cl_dir).unwrap(); - fs::write( - cl_dir.join("config.toml"), - r#"on_event = "tool:before" -on_tool = "bash" -match_input = "input.command.contains('git push')" -reset = "session" -"#, - ) - .unwrap(); - fs::write( - cl_dir.join("flow.mmd"), - r"stateDiagram-v2 - [*] --> check - check --> pass - check --> skip - pass --> [*] - skip --> [*] - check: Did you check? - pass: Yes, it passed - skip: No, skipped because -", - ) - .unwrap(); - - let event = make_event("tool:before", "bash", "git push origin main", "sess-branch"); - let resp = run(&event, tmp.path()).unwrap(); - if let HookResponse::Block { message } = resp { - assert!(message.contains("pass")); - assert!(message.contains("skip")); - assert!(message.contains("run one of:")); - } else { - panic!("expected block"); - } - } - - #[test] - fn reset_always_with_allow_preview_does_not_show_preview_tip() { - let tmp = TempDir::new().unwrap(); - let cl_dir = tmp.path().join(".steplock/checklists/always-preview"); - fs::create_dir_all(&cl_dir).unwrap(); - fs::write( - cl_dir.join("config.toml"), - r#"on_event = "tool:before" -on_tool = "bash" -match_input = "input.command.contains('git push')" -reset = "always" -allow_preview_request = true -"#, - ) - .unwrap(); - fs::write( - cl_dir.join("flow.mmd"), - "stateDiagram-v2\n [*] --> check\n check --> [*]\n check: Did you check?\n", - ) - .unwrap(); - - let event = make_event("tool:before", "bash", "git push origin main", "sess-ap"); - let resp = run(&event, tmp.path()).unwrap(); - if let HookResponse::Block { message } = resp { - // preview.sh is never written for reset=always, so tip must not appear - assert!( - !message.contains("preview.sh"), - "should not reference non-existent preview.sh: {message}" - ); - } else { - panic!("expected block"); - } - } - - #[test] - fn reset_always_with_branching_flow_shows_no_next_state() { - let tmp = TempDir::new().unwrap(); - let cl_dir = tmp.path().join(".steplock/checklists/always-branch"); - fs::create_dir_all(&cl_dir).unwrap(); - fs::write( - cl_dir.join("config.toml"), - r#"on_event = "tool:before" -on_tool = "bash" -match_input = "input.command.contains('git push')" -reset = "always" -"#, - ) - .unwrap(); - fs::write( - cl_dir.join("flow.mmd"), - r"stateDiagram-v2 - [*] --> check - check --> pass - check --> skip - pass --> [*] - skip --> [*] - check: Did you check? - pass: Yes - skip: No -", - ) - .unwrap(); - - let event = make_event("tool:before", "bash", "git push origin main", "sess-ab"); - let resp = run(&event, tmp.path()).unwrap(); - if let HookResponse::Block { message } = resp { - // reset=always: no ack.sh, so no branch options — just the question + retry prompt - assert!(message.contains("Did you check?")); - assert!(message.contains("retry your original command")); - } else { - panic!("expected block"); - } - } - - #[test] - fn session_stop_removes_scope_dir() { - let tmp = TempDir::new().unwrap(); - setup_checklist(tmp.path()); - - // First block creates the session dir - let event = make_event("tool:before", "bash", "git push origin main", "sess-stop"); - run(&event, tmp.path()).unwrap(); - let scope_dir = tmp.path().join(".steplock/sessions/sess-stop"); - assert!(scope_dir.exists()); - - // session:stop removes the scope dir - let stop = HookEvent { - event: "session:stop".to_owned(), - tool: String::new(), - input: HashMap::new(), - output: HashMap::new(), - session_id: "sess-stop".to_owned(), - caller: "claude-code".to_owned(), - }; - let resp = run(&stop, tmp.path()).unwrap(); - assert!(matches!(resp, HookResponse::Approve)); - assert!(!scope_dir.exists()); - } - - #[test] - fn session_stop_approves_when_no_scope_dir() { - let tmp = TempDir::new().unwrap(); - setup_checklist(tmp.path()); - let stop = HookEvent { - event: "session:stop".to_owned(), - tool: String::new(), - input: HashMap::new(), - output: HashMap::new(), - session_id: "nonexistent-session".to_owned(), - caller: "claude-code".to_owned(), - }; - let resp = run(&stop, tmp.path()).unwrap(); - assert!(matches!(resp, HookResponse::Approve)); - } - - #[test] - fn session_stop_approves_when_no_steplock_dir() { - let tmp = TempDir::new().unwrap(); - let stop = HookEvent { - event: "session:stop".to_owned(), - tool: String::new(), - input: HashMap::new(), - output: HashMap::new(), - session_id: "sess-x".to_owned(), - caller: "claude-code".to_owned(), - }; - let resp = run(&stop, tmp.path()).unwrap(); - assert!(matches!(resp, HookResponse::Approve)); - } - - #[test] - fn session_stop_uses_fallback_id_when_session_id_empty() { - let tmp = TempDir::new().unwrap(); - setup_checklist(tmp.path()); - - // First block with empty session_id creates fallback-id and a scope dir - let mut input = HashMap::new(); - input.insert( - "command".to_owned(), - serde_json::Value::String("git push".to_owned()), - ); - let no_session = HookEvent { - event: "tool:before".to_owned(), - tool: "bash".to_owned(), - input, - output: HashMap::new(), - session_id: String::new(), - caller: "unknown".to_owned(), - }; - run(&no_session, tmp.path()).unwrap(); - - let fallback_id = - fs::read_to_string(tmp.path().join(".steplock/sessions/fallback-id")).unwrap(); - let fallback_id = fallback_id.trim(); - let scope_dir = tmp.path().join(".steplock/sessions").join(fallback_id); - assert!(scope_dir.exists()); - - // session:stop with empty session_id uses fallback-id to clean up - let stop = HookEvent { - event: "session:stop".to_owned(), - tool: String::new(), - input: HashMap::new(), - output: HashMap::new(), - session_id: String::new(), - caller: "unknown".to_owned(), - }; - let resp = run(&stop, tmp.path()).unwrap(); - assert!(matches!(resp, HookResponse::Approve)); - assert!(!scope_dir.exists()); - } - - // Simulates what ack.sh does: advance current_state to next and add cur to visited. - fn simulate_ack(state_path: &Path, next: &str) { - let mut s = load_state(state_path).unwrap(); - s.visited.push(s.current_state.clone()); - s.current_state = next.to_owned(); - s.next_state = None; - save_state(state_path, &s).unwrap(); - } - - #[test] - fn two_step_lifecycle_block_ack_block_ack_approve() { - let tmp = TempDir::new().unwrap(); - let cl_dir = tmp.path().join(".steplock/checklists/ddd-gate"); - fs::create_dir_all(&cl_dir).unwrap(); - fs::write( - cl_dir.join("config.toml"), - r#"on_event = "tool:before" -on_tool = "bash" -match_input = "input.command.contains('git push')" -reset = "session" -"#, - ) - .unwrap(); - fs::write( - cl_dir.join("flow.mmd"), - "stateDiagram-v2\n [*] --> step_one\n step_one --> step_two\n step_two --> [*]\n step_one : Did you do step one?\n step_two : Did you do step two?\n", - ) - .unwrap(); - - let event = make_event("tool:before", "bash", "git push origin main", "sess-lc"); - let state_path = tmp - .path() - .join(".steplock/sessions/sess-lc/ddd-gate/state.json"); - - // Run 1 — no state yet; blocks on step_one, creates state with transitions - let resp1 = run(&event, tmp.path()).unwrap(); - assert!(matches!(resp1, HookResponse::Block { .. })); - let s1 = load_state(&state_path).unwrap(); - assert_eq!(s1.current_state, "step_one"); - assert_eq!(s1.transitions, vec!["step_two"]); - - // Simulate ack of step_one → step_two - simulate_ack(&state_path, "step_two"); - - // Run 2 — blocks on step_two, transitions updated to ["[*]"] - let resp2 = run(&event, tmp.path()).unwrap(); - assert!(matches!(resp2, HookResponse::Block { .. })); - let s2 = load_state(&state_path).unwrap(); - assert_eq!(s2.current_state, "step_two"); - assert_eq!(s2.transitions, vec!["[*]"]); - assert!(s2.visited.contains(&"step_one".to_owned())); - - // Simulate ack of step_two → [*] - simulate_ack(&state_path, "[*]"); - - // Run 3 — checklist complete, approves and resets state - let resp3 = run(&event, tmp.path()).unwrap(); - assert!(matches!(resp3, HookResponse::Approve)); - let s3 = load_state(&state_path).unwrap(); - assert_eq!(s3.current_state, "step_one"); // reset to initial - assert!(s3.visited.is_empty()); - } - - #[test] - fn state_persists_transitions_on_repeated_blocks() { - let tmp = TempDir::new().unwrap(); - setup_checklist(tmp.path()); - let event = make_event("tool:before", "bash", "git push origin main", "sess-rep"); - let state_path = tmp - .path() - .join(".steplock/sessions/sess-rep/quality-gate/state.json"); - - // Each call re-computes and writes the same transitions - run(&event, tmp.path()).unwrap(); - let s1 = load_state(&state_path).unwrap(); - run(&event, tmp.path()).unwrap(); - let s2 = load_state(&state_path).unwrap(); - assert_eq!(s1.transitions, s2.transitions); - assert_eq!(s1.current_state, s2.current_state); - } - - #[test] - fn unknown_current_state_in_flow_skips_checklist() { - let tmp = TempDir::new().unwrap(); - setup_checklist(tmp.path()); - - // Inject a state.json with a current_state not in flow - let session_dir = tmp - .path() - .join(".steplock/sessions/sess-stale/quality-gate"); - fs::create_dir_all(&session_dir).unwrap(); - let state = SessionState { - checklist: "quality-gate".to_owned(), - current_state: "nonexistent_state".to_owned(), - next_state: None, - transitions: vec![], - visited: vec![], - }; - save_state(&session_dir.join("state.json"), &state).unwrap(); - - let event = make_event("tool:before", "bash", "git push origin main", "sess-stale"); - let resp = run(&event, tmp.path()).unwrap(); - // Flow doesn't know this state → skip → approve - assert!(matches!(resp, HookResponse::Approve)); - } - - #[test] - fn complete_event_written_to_audit_log() { - let tmp = TempDir::new().unwrap(); - setup_checklist(tmp.path()); - - // Put state at [*] so the gate sees a completed checklist - let session_dir = tmp - .path() - .join(".steplock/sessions/sess-audit/quality-gate"); - fs::create_dir_all(&session_dir).unwrap(); - let state = SessionState { - checklist: "quality-gate".to_owned(), - current_state: "[*]".to_owned(), - next_state: None, - transitions: vec![], - visited: vec!["clean_code".to_owned()], - }; - save_state(&session_dir.join("state.json"), &state).unwrap(); - - let event = make_event("tool:before", "bash", "git push origin main", "sess-audit"); - let resp = run(&event, tmp.path()).unwrap(); - assert!(matches!(resp, HookResponse::Approve)); - - let log_path = tmp.path().join(".steplock/audit.log"); - assert!(log_path.exists(), "audit.log should exist"); - let content = fs::read_to_string(&log_path).unwrap(); - let entry: serde_json::Value = serde_json::from_str(content.trim()).unwrap(); - assert_eq!( - entry.get("event").and_then(|v| v.as_str()), - Some("complete") - ); - assert_eq!( - entry.get("checklist").and_then(|v| v.as_str()), - Some("quality-gate") - ); - assert_eq!( - entry.get("session").and_then(|v| v.as_str()), - Some("sess-audit") - ); - } -} +#[path = "run_tests.rs"] +mod tests; diff --git a/core/src/run_tests.rs b/core/src/run_tests.rs new file mode 100644 index 0000000..6286d7b --- /dev/null +++ b/core/src/run_tests.rs @@ -0,0 +1,687 @@ +//! Unit tests for `run`. +use super::*; +use crate::state::{load_state, save_state, SessionState}; +use std::collections::HashMap; +use tempfile::TempDir; + +fn make_event(event: &str, tool: &str, cmd: &str, session: &str) -> HookEvent { + let mut input = HashMap::new(); + input.insert( + "command".to_owned(), + serde_json::Value::String(cmd.to_owned()), + ); + HookEvent { + event: event.to_owned(), + tool: tool.to_owned(), + input, + output: HashMap::new(), + session_id: session.to_owned(), + caller: "claude-code".to_owned(), + } +} + +fn setup_checklist(root: &Path) { + let cl_dir = root.join(".steplock/checklists/quality-gate"); + fs::create_dir_all(&cl_dir).unwrap(); + + fs::write( + cl_dir.join("config.toml"), + r#"on_event = "tool:before" +on_tool = "bash" +match_input = "input.command.contains('git push')" +reset = "session" +"#, + ) + .unwrap(); + + fs::write( + cl_dir.join("flow.mmd"), + r"stateDiagram-v2 + [*] --> clean_code + clean_code --> [*] + clean_code: Did you write clean code? +", + ) + .unwrap(); +} + +#[test] +fn approves_non_matching_event() { + let tmp = TempDir::new().unwrap(); + setup_checklist(tmp.path()); + let event = make_event("tool:before", "bash", "ls -la", "sess-1"); + let resp = run(&event, tmp.path()).unwrap(); + assert!(matches!(resp, HookResponse::Approve)); +} + +#[test] +fn blocks_on_matching_event() { + let tmp = TempDir::new().unwrap(); + setup_checklist(tmp.path()); + let event = make_event("tool:before", "bash", "git push origin main", "sess-1"); + let resp = run(&event, tmp.path()).unwrap(); + assert!(matches!(resp, HookResponse::Block { .. })); +} + +#[test] +fn approves_and_resets_state_when_complete() { + let tmp = TempDir::new().unwrap(); + setup_checklist(tmp.path()); + + // State at [*] = checklist complete from a prior ack sequence + let session_dir = tmp.path().join(".steplock/sessions/sess-1/quality-gate"); + fs::create_dir_all(&session_dir).unwrap(); + let state = SessionState { + checklist: "quality-gate".to_owned(), + current_state: "[*]".to_owned(), + next_state: None, + transitions: vec![], + visited: vec!["clean_code".to_owned()], + }; + save_state(&session_dir.join("state.json"), &state).unwrap(); + + // This attempt is approved (checklist was already satisfied) + let event = make_event("tool:before", "bash", "git push origin main", "sess-1"); + let resp = run(&event, tmp.path()).unwrap(); + assert!(matches!(resp, HookResponse::Approve)); + + // State is now reset so the NEXT attempt starts fresh + let next_state = load_state(&session_dir.join("state.json")).unwrap(); + assert_eq!(next_state.current_state, "clean_code"); + assert!(next_state.visited.is_empty()); +} + +#[test] +fn approves_when_no_checklists_dir() { + let tmp = TempDir::new().unwrap(); + let event = make_event("tool:before", "bash", "git push origin main", "sess-1"); + let resp = run(&event, tmp.path()).unwrap(); + assert!(matches!(resp, HookResponse::Approve)); +} + +#[test] +fn approves_on_event_type_mismatch() { + let tmp = TempDir::new().unwrap(); + setup_checklist(tmp.path()); + let event = make_event("tool:after", "bash", "git push origin main", "sess-1"); + let resp = run(&event, tmp.path()).unwrap(); + assert!(matches!(resp, HookResponse::Approve)); +} + +#[test] +fn approves_on_tool_mismatch() { + let tmp = TempDir::new().unwrap(); + setup_checklist(tmp.path()); + let event = make_event( + "tool:before", + "write_file", + "git push origin main", + "sess-1", + ); + let resp = run(&event, tmp.path()).unwrap(); + assert!(matches!(resp, HookResponse::Approve)); +} + +#[test] +fn skips_checklist_dir_missing_files() { + let tmp = TempDir::new().unwrap(); + // Create dir but no config.toml / flow.mmd + let cl_dir = tmp.path().join(".steplock/checklists/empty-gate"); + fs::create_dir_all(&cl_dir).unwrap(); + let event = make_event("tool:before", "bash", "git push origin main", "sess-1"); + let resp = run(&event, tmp.path()).unwrap(); + assert!(matches!(resp, HookResponse::Approve)); +} + +#[test] +fn reset_always_blocks_every_time() { + let tmp = TempDir::new().unwrap(); + let cl_dir = tmp.path().join(".steplock/checklists/always-gate"); + fs::create_dir_all(&cl_dir).unwrap(); + fs::write( + cl_dir.join("config.toml"), + r#"on_event = "tool:before" +on_tool = "bash" +match_input = "input.command.contains('git push')" +reset = "always" +"#, + ) + .unwrap(); + fs::write( + cl_dir.join("flow.mmd"), + r"stateDiagram-v2 + [*] --> check + check --> [*] + check: Did you check? +", + ) + .unwrap(); + + let event = make_event("tool:before", "bash", "git push origin main", "sess-x"); + let resp = run(&event, tmp.path()).unwrap(); + assert!(matches!(resp, HookResponse::Block { .. })); + + // Second invocation still blocks (no state persistence) + let resp2 = run(&event, tmp.path()).unwrap(); + assert!(matches!(resp2, HookResponse::Block { .. })); +} + +#[test] +fn block_message_contains_label() { + let tmp = TempDir::new().unwrap(); + setup_checklist(tmp.path()); + let event = make_event("tool:before", "bash", "git push origin main", "sess-1"); + let resp = run(&event, tmp.path()).unwrap(); + if let HookResponse::Block { message } = resp { + assert!(message.contains("Did you write clean code?")); + } else { + panic!("expected block"); + } +} + +#[test] +fn block_message_contains_checklist_name() { + let tmp = TempDir::new().unwrap(); + setup_checklist(tmp.path()); + let event = make_event("tool:before", "bash", "git push origin main", "sess-name"); + let resp = run(&event, tmp.path()).unwrap(); + if let HookResponse::Block { message } = resp { + // The checklist dir is "quality-gate" — it must appear in the message prefix + assert!( + message.starts_with("[quality-gate:"), + "expected [quality-gate: prefix, got: {message}" + ); + } else { + panic!("expected block"); + } +} + +#[test] +fn block_message_contains_ack_sh_path() { + let tmp = TempDir::new().unwrap(); + setup_checklist(tmp.path()); + let event = make_event("tool:before", "bash", "git push origin main", "sess-1"); + let resp = run(&event, tmp.path()).unwrap(); + if let HookResponse::Block { message } = resp { + assert!(message.contains("ack.sh")); + } else { + panic!("expected block"); + } +} + +#[test] +fn block_message_shows_step_progress() { + let tmp = TempDir::new().unwrap(); + // 3-step linear flow: a → b → c → [*] + let cl_dir = tmp.path().join(".steplock/checklists/progress-gate"); + fs::create_dir_all(&cl_dir).unwrap(); + fs::write( + cl_dir.join("config.toml"), + "on_event = \"tool:before\"\non_tool = \"bash\"\nmatch_input = \"input.command.contains('git push')\"\nreset = \"session\"\n", + ) + .unwrap(); + fs::write( + cl_dir.join("flow.mmd"), + "stateDiagram-v2\n [*] --> a\n a --> b\n b --> c\n c --> [*]\n a: Step A\n b: Step B\n c: Step C\n", + ) + .unwrap(); + + // First block: step 1/3 + let event = make_event("tool:before", "bash", "git push", "sess-prog"); + let resp = run(&event, tmp.path()).unwrap(); + if let HookResponse::Block { message } = resp { + assert!( + message.contains("1/3"), + "expected 1/3 in message, got: {message}" + ); + } else { + panic!("expected block"); + } + + // Advance state manually to simulate ack + let state_path = tmp + .path() + .join(".steplock/sessions/sess-prog/progress-gate/state.json"); + let mut state = load_state(&state_path).unwrap(); + state.visited.push(state.current_state.clone()); + state.current_state = "b".to_owned(); + state.next_state = Some("c".to_owned()); + state.transitions = vec!["c".to_owned()]; + save_state(&state_path, &state).unwrap(); + + // Second block: step 2/3 + let resp2 = run(&event, tmp.path()).unwrap(); + if let HookResponse::Block { message } = resp2 { + assert!( + message.contains("2/3"), + "expected 2/3 in message, got: {message}" + ); + } else { + panic!("expected block"); + } +} + +#[test] +fn fallback_session_id_generated_when_empty() { + let tmp = TempDir::new().unwrap(); + setup_checklist(tmp.path()); + // Empty session_id triggers fallback UUID generation + let mut input = HashMap::new(); + input.insert( + "command".to_owned(), + serde_json::Value::String("git push".to_owned()), + ); + let event = HookEvent { + event: "tool:before".to_owned(), + tool: "bash".to_owned(), + input, + output: HashMap::new(), + session_id: String::new(), + caller: "unknown".to_owned(), + }; + let resp = run(&event, tmp.path()).unwrap(); + assert!(matches!(resp, HookResponse::Block { .. })); + // fallback-id file created + assert!(tmp.path().join(".steplock/sessions/fallback-id").exists()); + + // Second invocation reuses the same fallback ID + let resp2 = run(&event, tmp.path()).unwrap(); + assert!(matches!(resp2, HookResponse::Block { .. })); +} + +#[test] +fn allow_preview_request_adds_tip() { + let tmp = TempDir::new().unwrap(); + let cl_dir = tmp.path().join(".steplock/checklists/preview-gate"); + fs::create_dir_all(&cl_dir).unwrap(); + fs::write( + cl_dir.join("config.toml"), + r#"on_event = "tool:before" +on_tool = "bash" +match_input = "input.command.contains('git push')" +reset = "session" +allow_preview_request = true +"#, + ) + .unwrap(); + fs::write( + cl_dir.join("flow.mmd"), + r"stateDiagram-v2 + [*] --> check + check --> [*] + check: Did you check? +", + ) + .unwrap(); + + let event = make_event( + "tool:before", + "bash", + "git push origin main", + "sess-preview", + ); + let resp = run(&event, tmp.path()).unwrap(); + if let HookResponse::Block { message } = resp { + assert!(message.contains("preview.sh")); + } else { + panic!("expected block"); + } +} + +#[test] +fn branching_flow_block_message_lists_options() { + let tmp = TempDir::new().unwrap(); + let cl_dir = tmp.path().join(".steplock/checklists/branch-gate"); + fs::create_dir_all(&cl_dir).unwrap(); + fs::write( + cl_dir.join("config.toml"), + r#"on_event = "tool:before" +on_tool = "bash" +match_input = "input.command.contains('git push')" +reset = "session" +"#, + ) + .unwrap(); + fs::write( + cl_dir.join("flow.mmd"), + r"stateDiagram-v2 + [*] --> check + check --> pass + check --> skip + pass --> [*] + skip --> [*] + check: Did you check? + pass: Yes, it passed + skip: No, skipped because +", + ) + .unwrap(); + + let event = make_event("tool:before", "bash", "git push origin main", "sess-branch"); + let resp = run(&event, tmp.path()).unwrap(); + if let HookResponse::Block { message } = resp { + assert!(message.contains("pass")); + assert!(message.contains("skip")); + assert!(message.contains("run one of:")); + } else { + panic!("expected block"); + } +} + +#[test] +fn reset_always_with_allow_preview_does_not_show_preview_tip() { + let tmp = TempDir::new().unwrap(); + let cl_dir = tmp.path().join(".steplock/checklists/always-preview"); + fs::create_dir_all(&cl_dir).unwrap(); + fs::write( + cl_dir.join("config.toml"), + r#"on_event = "tool:before" +on_tool = "bash" +match_input = "input.command.contains('git push')" +reset = "always" +allow_preview_request = true +"#, + ) + .unwrap(); + fs::write( + cl_dir.join("flow.mmd"), + "stateDiagram-v2\n [*] --> check\n check --> [*]\n check: Did you check?\n", + ) + .unwrap(); + + let event = make_event("tool:before", "bash", "git push origin main", "sess-ap"); + let resp = run(&event, tmp.path()).unwrap(); + if let HookResponse::Block { message } = resp { + // preview.sh is never written for reset=always, so tip must not appear + assert!( + !message.contains("preview.sh"), + "should not reference non-existent preview.sh: {message}" + ); + } else { + panic!("expected block"); + } +} + +#[test] +fn reset_always_with_branching_flow_shows_no_next_state() { + let tmp = TempDir::new().unwrap(); + let cl_dir = tmp.path().join(".steplock/checklists/always-branch"); + fs::create_dir_all(&cl_dir).unwrap(); + fs::write( + cl_dir.join("config.toml"), + r#"on_event = "tool:before" +on_tool = "bash" +match_input = "input.command.contains('git push')" +reset = "always" +"#, + ) + .unwrap(); + fs::write( + cl_dir.join("flow.mmd"), + r"stateDiagram-v2 + [*] --> check + check --> pass + check --> skip + pass --> [*] + skip --> [*] + check: Did you check? + pass: Yes + skip: No +", + ) + .unwrap(); + + let event = make_event("tool:before", "bash", "git push origin main", "sess-ab"); + let resp = run(&event, tmp.path()).unwrap(); + if let HookResponse::Block { message } = resp { + // reset=always: no ack.sh, so no branch options — just the question + retry prompt + assert!(message.contains("Did you check?")); + assert!(message.contains("retry your original command")); + } else { + panic!("expected block"); + } +} + +#[test] +fn session_stop_removes_scope_dir() { + let tmp = TempDir::new().unwrap(); + setup_checklist(tmp.path()); + + // First block creates the session dir + let event = make_event("tool:before", "bash", "git push origin main", "sess-stop"); + run(&event, tmp.path()).unwrap(); + let scope_dir = tmp.path().join(".steplock/sessions/sess-stop"); + assert!(scope_dir.exists()); + + // session:stop removes the scope dir + let stop = HookEvent { + event: "session:stop".to_owned(), + tool: String::new(), + input: HashMap::new(), + output: HashMap::new(), + session_id: "sess-stop".to_owned(), + caller: "claude-code".to_owned(), + }; + let resp = run(&stop, tmp.path()).unwrap(); + assert!(matches!(resp, HookResponse::Approve)); + assert!(!scope_dir.exists()); +} + +#[test] +fn session_stop_approves_when_no_scope_dir() { + let tmp = TempDir::new().unwrap(); + setup_checklist(tmp.path()); + let stop = HookEvent { + event: "session:stop".to_owned(), + tool: String::new(), + input: HashMap::new(), + output: HashMap::new(), + session_id: "nonexistent-session".to_owned(), + caller: "claude-code".to_owned(), + }; + let resp = run(&stop, tmp.path()).unwrap(); + assert!(matches!(resp, HookResponse::Approve)); +} + +#[test] +fn session_stop_approves_when_no_steplock_dir() { + let tmp = TempDir::new().unwrap(); + let stop = HookEvent { + event: "session:stop".to_owned(), + tool: String::new(), + input: HashMap::new(), + output: HashMap::new(), + session_id: "sess-x".to_owned(), + caller: "claude-code".to_owned(), + }; + let resp = run(&stop, tmp.path()).unwrap(); + assert!(matches!(resp, HookResponse::Approve)); +} + +#[test] +fn session_stop_uses_fallback_id_when_session_id_empty() { + let tmp = TempDir::new().unwrap(); + setup_checklist(tmp.path()); + + // First block with empty session_id creates fallback-id and a scope dir + let mut input = HashMap::new(); + input.insert( + "command".to_owned(), + serde_json::Value::String("git push".to_owned()), + ); + let no_session = HookEvent { + event: "tool:before".to_owned(), + tool: "bash".to_owned(), + input, + output: HashMap::new(), + session_id: String::new(), + caller: "unknown".to_owned(), + }; + run(&no_session, tmp.path()).unwrap(); + + let fallback_id = + fs::read_to_string(tmp.path().join(".steplock/sessions/fallback-id")).unwrap(); + let fallback_id = fallback_id.trim(); + let scope_dir = tmp.path().join(".steplock/sessions").join(fallback_id); + assert!(scope_dir.exists()); + + // session:stop with empty session_id uses fallback-id to clean up + let stop = HookEvent { + event: "session:stop".to_owned(), + tool: String::new(), + input: HashMap::new(), + output: HashMap::new(), + session_id: String::new(), + caller: "unknown".to_owned(), + }; + let resp = run(&stop, tmp.path()).unwrap(); + assert!(matches!(resp, HookResponse::Approve)); + assert!(!scope_dir.exists()); +} + +// Simulates what ack.sh does: advance current_state to next and add cur to visited. +fn simulate_ack(state_path: &Path, next: &str) { + let mut s = load_state(state_path).unwrap(); + s.visited.push(s.current_state.clone()); + s.current_state = next.to_owned(); + s.next_state = None; + save_state(state_path, &s).unwrap(); +} + +#[test] +fn two_step_lifecycle_block_ack_block_ack_approve() { + let tmp = TempDir::new().unwrap(); + let cl_dir = tmp.path().join(".steplock/checklists/ddd-gate"); + fs::create_dir_all(&cl_dir).unwrap(); + fs::write( + cl_dir.join("config.toml"), + r#"on_event = "tool:before" +on_tool = "bash" +match_input = "input.command.contains('git push')" +reset = "session" +"#, + ) + .unwrap(); + fs::write( + cl_dir.join("flow.mmd"), + "stateDiagram-v2\n [*] --> step_one\n step_one --> step_two\n step_two --> [*]\n step_one : Did you do step one?\n step_two : Did you do step two?\n", + ) + .unwrap(); + + let event = make_event("tool:before", "bash", "git push origin main", "sess-lc"); + let state_path = tmp + .path() + .join(".steplock/sessions/sess-lc/ddd-gate/state.json"); + + // Run 1 — no state yet; blocks on step_one, creates state with transitions + let resp1 = run(&event, tmp.path()).unwrap(); + assert!(matches!(resp1, HookResponse::Block { .. })); + let s1 = load_state(&state_path).unwrap(); + assert_eq!(s1.current_state, "step_one"); + assert_eq!(s1.transitions, vec!["step_two"]); + + // Simulate ack of step_one → step_two + simulate_ack(&state_path, "step_two"); + + // Run 2 — blocks on step_two, transitions updated to ["[*]"] + let resp2 = run(&event, tmp.path()).unwrap(); + assert!(matches!(resp2, HookResponse::Block { .. })); + let s2 = load_state(&state_path).unwrap(); + assert_eq!(s2.current_state, "step_two"); + assert_eq!(s2.transitions, vec!["[*]"]); + assert!(s2.visited.contains(&"step_one".to_owned())); + + // Simulate ack of step_two → [*] + simulate_ack(&state_path, "[*]"); + + // Run 3 — checklist complete, approves and resets state + let resp3 = run(&event, tmp.path()).unwrap(); + assert!(matches!(resp3, HookResponse::Approve)); + let s3 = load_state(&state_path).unwrap(); + assert_eq!(s3.current_state, "step_one"); // reset to initial + assert!(s3.visited.is_empty()); +} + +#[test] +fn state_persists_transitions_on_repeated_blocks() { + let tmp = TempDir::new().unwrap(); + setup_checklist(tmp.path()); + let event = make_event("tool:before", "bash", "git push origin main", "sess-rep"); + let state_path = tmp + .path() + .join(".steplock/sessions/sess-rep/quality-gate/state.json"); + + // Each call re-computes and writes the same transitions + run(&event, tmp.path()).unwrap(); + let s1 = load_state(&state_path).unwrap(); + run(&event, tmp.path()).unwrap(); + let s2 = load_state(&state_path).unwrap(); + assert_eq!(s1.transitions, s2.transitions); + assert_eq!(s1.current_state, s2.current_state); +} + +#[test] +fn unknown_current_state_in_flow_skips_checklist() { + let tmp = TempDir::new().unwrap(); + setup_checklist(tmp.path()); + + // Inject a state.json with a current_state not in flow + let session_dir = tmp + .path() + .join(".steplock/sessions/sess-stale/quality-gate"); + fs::create_dir_all(&session_dir).unwrap(); + let state = SessionState { + checklist: "quality-gate".to_owned(), + current_state: "nonexistent_state".to_owned(), + next_state: None, + transitions: vec![], + visited: vec![], + }; + save_state(&session_dir.join("state.json"), &state).unwrap(); + + let event = make_event("tool:before", "bash", "git push origin main", "sess-stale"); + let resp = run(&event, tmp.path()).unwrap(); + // Flow doesn't know this state → skip → approve + assert!(matches!(resp, HookResponse::Approve)); +} + +#[test] +fn complete_event_written_to_audit_log() { + let tmp = TempDir::new().unwrap(); + setup_checklist(tmp.path()); + + // Put state at [*] so the gate sees a completed checklist + let session_dir = tmp + .path() + .join(".steplock/sessions/sess-audit/quality-gate"); + fs::create_dir_all(&session_dir).unwrap(); + let state = SessionState { + checklist: "quality-gate".to_owned(), + current_state: "[*]".to_owned(), + next_state: None, + transitions: vec![], + visited: vec!["clean_code".to_owned()], + }; + save_state(&session_dir.join("state.json"), &state).unwrap(); + + let event = make_event("tool:before", "bash", "git push origin main", "sess-audit"); + let resp = run(&event, tmp.path()).unwrap(); + assert!(matches!(resp, HookResponse::Approve)); + + let log_path = tmp.path().join(".steplock/audit.log"); + assert!(log_path.exists(), "audit.log should exist"); + let content = fs::read_to_string(&log_path).unwrap(); + let entry: serde_json::Value = serde_json::from_str(content.trim()).unwrap(); + assert_eq!( + entry.get("event").and_then(|v| v.as_str()), + Some("complete") + ); + assert_eq!( + entry.get("checklist").and_then(|v| v.as_str()), + Some("quality-gate") + ); + assert_eq!( + entry.get("session").and_then(|v| v.as_str()), + Some("sess-audit") + ); +} diff --git a/core/src/scripts.rs b/core/src/scripts.rs index 2abb0e3..126245d 100644 --- a/core/src/scripts.rs +++ b/core/src/scripts.rs @@ -88,108 +88,5 @@ fn build_preview_sh(checklist_name: &str, flow: &FlowGraph) -> String { #[cfg(test)] #[allow(clippy::unwrap_used)] -mod tests { - use super::*; - use crate::flow::parse_mmd; - use std::fs; - use tempfile::TempDir; - - const SIMPLE_MMD: &str = r"stateDiagram-v2 - [*] --> step_one - step_one --> [*] - step_one : Do the first thing -"; - - const TWO_STEP_MMD: &str = r"stateDiagram-v2 - [*] --> step_one - step_one --> step_two - step_two --> [*] - step_one : First step - step_two : Second step -"; - - #[test] - fn ensure_ack_sh_creates_file() { - let tmp = TempDir::new().unwrap(); - ensure_ack_sh(tmp.path()).unwrap(); - let path = tmp.path().join("ack.sh"); - assert!(path.exists()); - let content = fs::read_to_string(&path).unwrap(); - assert!(content.contains("state.json")); - } - - #[test] - fn ack_sh_handles_complete_session() { - assert!(ACK_SH.contains("session already complete")); - assert!(ACK_SH.contains("[*]")); - } - - #[test] - fn ack_sh_appends_audit_event() { - assert!(ACK_SH.contains("audit.log")); - assert!(ACK_SH.contains("\"ack\"")); - } - - #[test] - fn ensure_ack_sh_is_idempotent() { - let tmp = TempDir::new().unwrap(); - let path = tmp.path().join("ack.sh"); - fs::write(&path, "custom content").unwrap(); - ensure_ack_sh(tmp.path()).unwrap(); - // Should not overwrite existing file - assert_eq!(fs::read_to_string(&path).unwrap(), "custom content"); - } - - #[test] - fn ensure_preview_sh_creates_file() { - let tmp = TempDir::new().unwrap(); - let flow = parse_mmd("test.mmd", SIMPLE_MMD).unwrap(); - ensure_preview_sh(tmp.path(), "my-checklist", &flow).unwrap(); - let path = tmp.path().join("preview.sh"); - assert!(path.exists()); - let content = fs::read_to_string(&path).unwrap(); - assert!(content.contains("my-checklist")); - assert!(content.contains("Do the first thing")); - } - - #[test] - fn ensure_preview_sh_is_idempotent() { - let tmp = TempDir::new().unwrap(); - let path = tmp.path().join("preview.sh"); - fs::write(&path, "custom").unwrap(); - let flow = parse_mmd("test.mmd", SIMPLE_MMD).unwrap(); - ensure_preview_sh(tmp.path(), "checklist", &flow).unwrap(); - assert_eq!(fs::read_to_string(&path).unwrap(), "custom"); - } - - #[test] - fn preview_sh_singular_item() { - let flow = parse_mmd("test.mmd", SIMPLE_MMD).unwrap(); - let script = build_preview_sh("my-gate", &flow); - assert!(script.contains("1 item)")); - assert!(!script.contains("1 items)")); - } - - #[test] - fn preview_sh_plural_items() { - let flow = parse_mmd("test.mmd", TWO_STEP_MMD).unwrap(); - let script = build_preview_sh("my-gate", &flow); - assert!(script.contains("2 items)")); - } - - #[test] - fn preview_sh_escapes_single_quotes() { - let mmd = "stateDiagram-v2\n [*] --> s\n s --> [*]\n s : It's fine\n"; - let flow = parse_mmd("test.mmd", mmd).unwrap(); - let script = build_preview_sh("checklist", &flow); - assert!(script.contains("It'\\''s fine")); - } - - #[test] - fn preview_sh_uses_state_name_as_fallback_label() { - let mmd = "stateDiagram-v2\n [*] --> unlabeled\n unlabeled --> [*]\n"; - let flow = parse_mmd("test.mmd", mmd).unwrap(); - let script = build_preview_sh("checklist", &flow); - assert!(script.contains("'unlabeled' 'unlabeled'")); - } -} +#[path = "scripts_tests.rs"] +mod tests; diff --git a/core/src/scripts_tests.rs b/core/src/scripts_tests.rs new file mode 100644 index 0000000..0992c61 --- /dev/null +++ b/core/src/scripts_tests.rs @@ -0,0 +1,104 @@ +//! Unit tests for `scripts`. +use super::*; +use crate::flow::parse_mmd; +use std::fs; +use tempfile::TempDir; + +const SIMPLE_MMD: &str = r"stateDiagram-v2 + [*] --> step_one + step_one --> [*] + step_one : Do the first thing +"; + +const TWO_STEP_MMD: &str = r"stateDiagram-v2 + [*] --> step_one + step_one --> step_two + step_two --> [*] + step_one : First step + step_two : Second step +"; + +#[test] +fn ensure_ack_sh_creates_file() { + let tmp = TempDir::new().unwrap(); + ensure_ack_sh(tmp.path()).unwrap(); + let path = tmp.path().join("ack.sh"); + assert!(path.exists()); + let content = fs::read_to_string(&path).unwrap(); + assert!(content.contains("state.json")); +} + +#[test] +fn ack_sh_handles_complete_session() { + assert!(ACK_SH.contains("session already complete")); + assert!(ACK_SH.contains("[*]")); +} + +#[test] +fn ack_sh_appends_audit_event() { + assert!(ACK_SH.contains("audit.log")); + assert!(ACK_SH.contains("\"ack\"")); +} + +#[test] +fn ensure_ack_sh_is_idempotent() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("ack.sh"); + fs::write(&path, "custom content").unwrap(); + ensure_ack_sh(tmp.path()).unwrap(); + // Should not overwrite existing file + assert_eq!(fs::read_to_string(&path).unwrap(), "custom content"); +} + +#[test] +fn ensure_preview_sh_creates_file() { + let tmp = TempDir::new().unwrap(); + let flow = parse_mmd("test.mmd", SIMPLE_MMD).unwrap(); + ensure_preview_sh(tmp.path(), "my-checklist", &flow).unwrap(); + let path = tmp.path().join("preview.sh"); + assert!(path.exists()); + let content = fs::read_to_string(&path).unwrap(); + assert!(content.contains("my-checklist")); + assert!(content.contains("Do the first thing")); +} + +#[test] +fn ensure_preview_sh_is_idempotent() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("preview.sh"); + fs::write(&path, "custom").unwrap(); + let flow = parse_mmd("test.mmd", SIMPLE_MMD).unwrap(); + ensure_preview_sh(tmp.path(), "checklist", &flow).unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), "custom"); +} + +#[test] +fn preview_sh_singular_item() { + let flow = parse_mmd("test.mmd", SIMPLE_MMD).unwrap(); + let script = build_preview_sh("my-gate", &flow); + assert!(script.contains("1 item)")); + assert!(!script.contains("1 items)")); +} + +#[test] +fn preview_sh_plural_items() { + let flow = parse_mmd("test.mmd", TWO_STEP_MMD).unwrap(); + let script = build_preview_sh("my-gate", &flow); + assert!(script.contains("2 items)")); +} + +#[test] +fn preview_sh_escapes_single_quotes() { + let mmd = "stateDiagram-v2\n [*] --> s\n s --> [*]\n s : It's fine\n"; + let flow = parse_mmd("test.mmd", mmd).unwrap(); + let script = build_preview_sh("checklist", &flow); + assert!(script.contains("It'\\''s fine")); +} + +#[test] +fn preview_sh_uses_state_name_as_fallback_label() { + let mmd = "stateDiagram-v2\n [*] --> unlabeled\n unlabeled --> [*]\n"; + let flow = parse_mmd("test.mmd", mmd).unwrap(); + let script = build_preview_sh("checklist", &flow); + assert!(script.contains("'unlabeled' 'unlabeled'")); +} diff --git a/core/src/state.rs b/core/src/state.rs index 6cb4d69..e749363 100644 --- a/core/src/state.rs +++ b/core/src/state.rs @@ -126,62 +126,5 @@ pub enum HookResponse { #[cfg(test)] #[allow(clippy::unwrap_used)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn init_state_sets_fields() { - let s = init_state("my-checklist", "first_step"); - assert_eq!(s.checklist, "my-checklist"); - assert_eq!(s.current_state, "first_step"); - assert!(s.next_state.is_none()); - assert!(s.transitions.is_empty()); - assert!(s.visited.is_empty()); - } - - #[test] - fn is_complete_false_when_active() { - let s = init_state("cl", "step_one"); - assert!(!s.is_complete()); - } - - #[test] - fn is_complete_true_at_end() { - let s = SessionState { - checklist: "cl".to_owned(), - current_state: "[*]".to_owned(), - next_state: None, - transitions: vec![], - visited: vec!["step_one".to_owned()], - }; - assert!(s.is_complete()); - } - - #[test] - fn save_and_load_roundtrip() { - let tmp = TempDir::new().unwrap(); - let path = tmp.path().join("state.json"); - let s = SessionState { - checklist: "gate".to_owned(), - current_state: "check_one".to_owned(), - next_state: Some("check_two".to_owned()), - transitions: vec!["check_two".to_owned()], - visited: vec!["prev".to_owned()], - }; - save_state(&path, &s).unwrap(); - let loaded = load_state(&path).unwrap(); - assert_eq!(loaded.checklist, s.checklist); - assert_eq!(loaded.current_state, s.current_state); - assert_eq!(loaded.next_state, s.next_state); - assert_eq!(loaded.transitions, s.transitions); - assert_eq!(loaded.visited, s.visited); - } - - #[test] - fn load_state_error_on_missing_file() { - let tmp = TempDir::new().unwrap(); - let path = tmp.path().join("nonexistent.json"); - load_state(&path).unwrap_err(); - } -} +#[path = "state_tests.rs"] +mod tests; diff --git a/core/src/state_tests.rs b/core/src/state_tests.rs new file mode 100644 index 0000000..0a3e0fe --- /dev/null +++ b/core/src/state_tests.rs @@ -0,0 +1,58 @@ +//! Unit tests for `state`. +use super::*; +use tempfile::TempDir; + +#[test] +fn init_state_sets_fields() { + let s = init_state("my-checklist", "first_step"); + assert_eq!(s.checklist, "my-checklist"); + assert_eq!(s.current_state, "first_step"); + assert!(s.next_state.is_none()); + assert!(s.transitions.is_empty()); + assert!(s.visited.is_empty()); +} + +#[test] +fn is_complete_false_when_active() { + let s = init_state("cl", "step_one"); + assert!(!s.is_complete()); +} + +#[test] +fn is_complete_true_at_end() { + let s = SessionState { + checklist: "cl".to_owned(), + current_state: "[*]".to_owned(), + next_state: None, + transitions: vec![], + visited: vec!["step_one".to_owned()], + }; + assert!(s.is_complete()); +} + +#[test] +fn save_and_load_roundtrip() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("state.json"); + let s = SessionState { + checklist: "gate".to_owned(), + current_state: "check_one".to_owned(), + next_state: Some("check_two".to_owned()), + transitions: vec!["check_two".to_owned()], + visited: vec!["prev".to_owned()], + }; + save_state(&path, &s).unwrap(); + let loaded = load_state(&path).unwrap(); + assert_eq!(loaded.checklist, s.checklist); + assert_eq!(loaded.current_state, s.current_state); + assert_eq!(loaded.next_state, s.next_state); + assert_eq!(loaded.transitions, s.transitions); + assert_eq!(loaded.visited, s.visited); +} + +#[test] +fn load_state_error_on_missing_file() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("nonexistent.json"); + load_state(&path).unwrap_err(); +} diff --git a/core/src/validate.rs b/core/src/validate.rs index f9d29cc..bbe44f6 100644 --- a/core/src/validate.rs +++ b/core/src/validate.rs @@ -65,87 +65,5 @@ fn check_file( #[cfg(test)] #[allow(clippy::unwrap_used)] -mod tests { - use std::path::Path; - - use super::*; - use std::fs; - use tempfile::TempDir; - - fn write_checklist(dir: &Path, name: &str, config: &str, flow: &str) { - let cl = dir.join(name); - fs::create_dir_all(&cl).unwrap(); - fs::write(cl.join("config.toml"), config).unwrap(); - fs::write(cl.join("flow.mmd"), flow).unwrap(); - } - - const GOOD_CONFIG: &str = r#"on_event = "tool:before" -on_tool = "bash" -match_input = "input.command.contains('git push')" -reset = "session" -"#; - - const GOOD_FLOW: &str = - "stateDiagram-v2\n [*] --> check\n check --> [*]\n check : Check it\n"; - - #[test] - fn valid_checklist_returns_no_errors() { - let tmp = TempDir::new().unwrap(); - write_checklist(tmp.path(), "my-gate", GOOD_CONFIG, GOOD_FLOW); - let errs = validate_checklists(tmp.path()); - assert!(errs.is_empty()); - } - - #[test] - fn bad_config_toml_is_reported() { - let tmp = TempDir::new().unwrap(); - write_checklist(tmp.path(), "bad-gate", "not valid toml !!!", GOOD_FLOW); - let errs = validate_checklists(tmp.path()); - assert_eq!(errs.len(), 1); - assert!(errs.first().is_some_and(|(l, _)| l.contains("config.toml"))); - } - - #[test] - fn bad_flow_mmd_is_reported() { - let tmp = TempDir::new().unwrap(); - write_checklist(tmp.path(), "bad-gate", GOOD_CONFIG, "not a mermaid diagram"); - let errs = validate_checklists(tmp.path()); - assert_eq!(errs.len(), 1); - assert!(errs.first().is_some_and(|(l, _)| l.contains("flow.mmd"))); - } - - #[test] - fn missing_config_toml_is_reported() { - let tmp = TempDir::new().unwrap(); - let cl = tmp.path().join("missing-config"); - fs::create_dir_all(&cl).unwrap(); - fs::write(cl.join("flow.mmd"), GOOD_FLOW).unwrap(); - // no config.toml - let errs = validate_checklists(tmp.path()); - assert_eq!(errs.len(), 1); - assert!(errs.first().is_some_and(|(l, _)| l.contains("config.toml"))); - } - - #[test] - fn multiple_bad_checklists_all_reported() { - let tmp = TempDir::new().unwrap(); - write_checklist(tmp.path(), "bad-1", "not toml", "not mermaid"); - write_checklist(tmp.path(), "bad-2", "not toml", "not mermaid"); - let errs = validate_checklists(tmp.path()); - assert_eq!(errs.len(), 4); // 2 config + 2 flow errors - } - - #[test] - fn empty_checklists_dir_returns_no_errors() { - let tmp = TempDir::new().unwrap(); - let errs = validate_checklists(tmp.path()); - assert!(errs.is_empty()); - } - - #[test] - fn nonexistent_dir_returns_no_errors() { - let tmp = TempDir::new().unwrap(); - let errs = validate_checklists(&tmp.path().join("does-not-exist")); - assert!(errs.is_empty()); - } -} +#[path = "validate_tests.rs"] +mod tests; diff --git a/core/src/validate_tests.rs b/core/src/validate_tests.rs new file mode 100644 index 0000000..6766823 --- /dev/null +++ b/core/src/validate_tests.rs @@ -0,0 +1,83 @@ +//! Unit tests for `validate`. +use std::path::Path; + +use super::*; +use std::fs; +use tempfile::TempDir; + +fn write_checklist(dir: &Path, name: &str, config: &str, flow: &str) { + let cl = dir.join(name); + fs::create_dir_all(&cl).unwrap(); + fs::write(cl.join("config.toml"), config).unwrap(); + fs::write(cl.join("flow.mmd"), flow).unwrap(); +} + +const GOOD_CONFIG: &str = r#"on_event = "tool:before" +on_tool = "bash" +match_input = "input.command.contains('git push')" +reset = "session" +"#; + +const GOOD_FLOW: &str = + "stateDiagram-v2\n [*] --> check\n check --> [*]\n check : Check it\n"; + +#[test] +fn valid_checklist_returns_no_errors() { + let tmp = TempDir::new().unwrap(); + write_checklist(tmp.path(), "my-gate", GOOD_CONFIG, GOOD_FLOW); + let errs = validate_checklists(tmp.path()); + assert!(errs.is_empty()); +} + +#[test] +fn bad_config_toml_is_reported() { + let tmp = TempDir::new().unwrap(); + write_checklist(tmp.path(), "bad-gate", "not valid toml !!!", GOOD_FLOW); + let errs = validate_checklists(tmp.path()); + assert_eq!(errs.len(), 1); + assert!(errs.first().is_some_and(|(l, _)| l.contains("config.toml"))); +} + +#[test] +fn bad_flow_mmd_is_reported() { + let tmp = TempDir::new().unwrap(); + write_checklist(tmp.path(), "bad-gate", GOOD_CONFIG, "not a mermaid diagram"); + let errs = validate_checklists(tmp.path()); + assert_eq!(errs.len(), 1); + assert!(errs.first().is_some_and(|(l, _)| l.contains("flow.mmd"))); +} + +#[test] +fn missing_config_toml_is_reported() { + let tmp = TempDir::new().unwrap(); + let cl = tmp.path().join("missing-config"); + fs::create_dir_all(&cl).unwrap(); + fs::write(cl.join("flow.mmd"), GOOD_FLOW).unwrap(); + // no config.toml + let errs = validate_checklists(tmp.path()); + assert_eq!(errs.len(), 1); + assert!(errs.first().is_some_and(|(l, _)| l.contains("config.toml"))); +} + +#[test] +fn multiple_bad_checklists_all_reported() { + let tmp = TempDir::new().unwrap(); + write_checklist(tmp.path(), "bad-1", "not toml", "not mermaid"); + write_checklist(tmp.path(), "bad-2", "not toml", "not mermaid"); + let errs = validate_checklists(tmp.path()); + assert_eq!(errs.len(), 4); // 2 config + 2 flow errors +} + +#[test] +fn empty_checklists_dir_returns_no_errors() { + let tmp = TempDir::new().unwrap(); + let errs = validate_checklists(tmp.path()); + assert!(errs.is_empty()); +} + +#[test] +fn nonexistent_dir_returns_no_errors() { + let tmp = TempDir::new().unwrap(); + let errs = validate_checklists(&tmp.path().join("does-not-exist")); + assert!(errs.is_empty()); +} From 18c40e4bcb98471452ab07bf25093dd87a4ac787 Mon Sep 17 00:00:00 2001 From: Ofek Gabay Date: Thu, 24 Sep 2026 11:19:00 +0300 Subject: [PATCH 2/2] chore(lint): enable clippy::items_after_statements Co-Authored-By: Claude Opus 5.5 (1M context) --- core/Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/core/Cargo.toml b/core/Cargo.toml index 5d4e6e2..943d159 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -85,6 +85,7 @@ wildcard_imports = "deny" enum_glob_use = "deny" single_match_else = "deny" manual_let_else = "deny" +items_after_statements = "deny" [dependencies] cel-interpreter = "0.10"