chore: rename packages/ to crates/

Move all 29 workspace members from packages/<name>/ to crates/<name>/.
Updates: workspace Cargo.toml (members + path deps), justfile, root
CLAUDE.md, scripts/build/CARGO_INSTALL.md, docs/architecture/crates.md
(renamed from packages.md), structural references in docs/architecture
and docs/configuration, per-crate CLAUDE.md self-references. Historical
plans, reports, and building/ docs are left untouched.

No behavior change; just check-all stays green and fermata tests pass.
This commit is contained in:
2026-04-30 21:58:57 +02:00
commit 97001e1544
26 changed files with 1678 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
use assert_cmd::Command;
use predicates::prelude::*;
use std::fs;
#[test]
fn check_blocks_botignore_match() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join(".botignore"), ".env\n").unwrap();
let target = tmp.path().join(".env");
fs::write(&target, "").unwrap();
Command::cargo_bin("fermata")
.unwrap()
.args(["check", "--op", "read", target.to_str().unwrap()])
.assert()
.failure()
.code(1)
.stdout(predicate::str::contains(".env"));
}
#[test]
fn check_allows_unmatched() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join(".botignore"), ".env\n").unwrap();
let target = tmp.path().join("src.rs");
fs::write(&target, "").unwrap();
Command::cargo_bin("fermata")
.unwrap()
.args(["check", "--op", "read", target.to_str().unwrap()])
.assert()
.success();
}
#[test]
fn check_emits_json_with_flag() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join(".botignore"), ".env\n").unwrap();
let target = tmp.path().join(".env");
fs::write(&target, "").unwrap();
let out = Command::cargo_bin("fermata")
.unwrap()
.args(["check", "--op", "read", "--json", target.to_str().unwrap()])
.assert()
.failure()
.get_output()
.stdout
.clone();
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(v["kind"], "deny");
}
+69
View File
@@ -0,0 +1,69 @@
use assert_cmd::Command;
use std::fs;
#[test]
fn hook_blocks_read_of_botignore_match() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join(".botignore"), ".env\n").unwrap();
let target = tmp.path().join(".env");
fs::write(&target, "").unwrap();
let payload = serde_json::json!({
"tool_name": "Read",
"tool_input": { "file_path": target.to_str().unwrap() }
})
.to_string();
let out = Command::cargo_bin("fermata")
.unwrap()
.args(["hook", "--harness", "claude"])
.write_stdin(payload)
.assert()
.success() // hook always exits 0
.get_output()
.stdout
.clone();
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
assert!(v["hookSpecificOutput"]["permissionDecisionReason"]
.as_str()
.unwrap()
.contains(".env"));
}
#[test]
fn hook_allows_unrelated_read() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join(".botignore"), ".env\n").unwrap();
let target = tmp.path().join("src.rs");
fs::write(&target, "").unwrap();
let payload = serde_json::json!({
"tool_name": "Read",
"tool_input": { "file_path": target.to_str().unwrap() }
})
.to_string();
let out = Command::cargo_bin("fermata")
.unwrap()
.args(["hook", "--harness", "claude"])
.write_stdin(payload)
.assert()
.success()
.get_output()
.stdout
.clone();
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "allow");
}
#[test]
fn hook_unknown_harness_errors() {
Command::cargo_bin("fermata")
.unwrap()
.args(["hook", "--harness", "doesnotexist"])
.write_stdin("{}")
.assert()
.code(2);
}
+135
View File
@@ -0,0 +1,135 @@
use dirigent_fermata::core::botignore::BotignoreSet;
use std::fs;
use tempfile::TempDir;
#[test]
fn matches_simple_pattern() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(root.join(".botignore"), ".env\nsecrets/\n").unwrap();
let set = BotignoreSet::load(root).unwrap();
let env = root.join(".env");
fs::write(&env, "").unwrap();
let m = set.matched(&env).unwrap();
assert!(m.is_some(), ".env should be matched");
assert_eq!(m.unwrap().pattern, ".env");
}
#[test]
fn does_not_match_unrelated_files() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(root.join(".botignore"), ".env\n").unwrap();
let set = BotignoreSet::load(root).unwrap();
let other = root.join("README.md");
fs::write(&other, "").unwrap();
assert!(set.matched(&other).unwrap().is_none());
}
#[test]
fn negation_pattern_excludes() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(root.join(".botignore"), "*.log\n!keep.log\n").unwrap();
let set = BotignoreSet::load(root).unwrap();
let blocked = root.join("foo.log");
fs::write(&blocked, "").unwrap();
assert!(set.matched(&blocked).unwrap().is_some());
let allowed = root.join("keep.log");
fs::write(&allowed, "").unwrap();
assert!(set.matched(&allowed).unwrap().is_none());
}
#[test]
fn empty_or_missing_botignore_is_ok() {
let tmp = TempDir::new().unwrap();
let set = BotignoreSet::load(tmp.path()).unwrap();
let any = tmp.path().join("anything.txt");
std::fs::write(&any, "").unwrap();
assert!(set.matched(&any).unwrap().is_none());
}
#[test]
fn nested_botignore_is_scoped_to_its_directory() {
// A `.botignore` in a subdirectory only applies under that subdirectory,
// matching gitignore semantics: a sibling file with the same name at the
// root is NOT affected.
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join("frontend")).unwrap();
fs::write(root.join("frontend/.botignore"), "secret.key\n").unwrap();
let set = BotignoreSet::load(root).unwrap();
let blocked = root.join("frontend/secret.key");
fs::write(&blocked, "").unwrap();
let m = set
.matched(&blocked)
.unwrap()
.expect("frontend/secret.key should match");
let src = m.source.to_string_lossy().replace('\\', "/");
assert!(
src.ends_with("frontend/.botignore"),
"Rule.source should point at the nested file; was {}",
src,
);
let unblocked = root.join("secret.key");
fs::write(&unblocked, "").unwrap();
assert!(
set.matched(&unblocked).unwrap().is_none(),
"top-level secret.key should NOT be matched (rule scoped to frontend/)",
);
}
#[test]
fn nested_botignore_anchored_pattern_is_local() {
// A leading `/` anchors the pattern to the directory of the .botignore
// file it's declared in.
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join("frontend")).unwrap();
fs::write(root.join("frontend/.botignore"), "/secret.key\n").unwrap();
let set = BotignoreSet::load(root).unwrap();
let blocked = root.join("frontend/secret.key");
fs::write(&blocked, "").unwrap();
assert!(set.matched(&blocked).unwrap().is_some());
let unblocked = root.join("secret.key");
fs::write(&unblocked, "").unwrap();
assert!(
set.matched(&unblocked).unwrap().is_none(),
"anchored /secret.key should NOT match outside frontend/",
);
}
#[test]
fn nested_botignore_overrides_root() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(root.join(".botignore"), "*.log\n").unwrap();
fs::create_dir_all(root.join("logs")).unwrap();
fs::write(root.join("logs/.botignore"), "!keep.log\n").unwrap();
let set = BotignoreSet::load(root).unwrap();
let blocked = root.join("logs/foo.log");
fs::write(&blocked, "").unwrap();
assert!(set.matched(&blocked).unwrap().is_some());
let kept = root.join("logs/keep.log");
fs::write(&kept, "").unwrap();
assert!(
set.matched(&kept).unwrap().is_none(),
"logs/.botignore should un-ignore keep.log",
);
}
+39
View File
@@ -0,0 +1,39 @@
use dirigent_fermata::core::extract::{extract_path_candidates, Confidence};
#[test]
fn extracts_absolute_unix_path() {
let s = "the file is at /home/user/.env and was modified";
let cs = extract_path_candidates(s);
assert!(cs.iter().any(|c| c.text == "/home/user/.env" && c.confidence == Confidence::High));
}
#[test]
fn extracts_absolute_windows_path() {
let s = r"see C:\Users\me\secret.toml for details";
let cs = extract_path_candidates(s);
assert!(cs.iter().any(|c| c.text == r"C:\Users\me\secret.toml" && c.confidence == Confidence::High));
}
#[test]
fn extracts_relative_with_separator() {
let s = "modified src/lib.rs and tests/foo.rs";
let cs = extract_path_candidates(s);
let texts: Vec<_> = cs.iter().map(|c| c.text.as_str()).collect();
assert!(texts.contains(&"src/lib.rs"));
assert!(texts.contains(&"tests/foo.rs"));
}
#[test]
fn bare_filename_with_extension_is_low_confidence() {
let s = "open README.md please";
let cs = extract_path_candidates(s);
let r = cs.iter().find(|c| c.text == "README.md").unwrap();
assert_eq!(r.confidence, Confidence::Low);
}
#[test]
fn ignores_pure_words() {
let s = "the quick brown fox";
let cs = extract_path_candidates(s);
assert!(cs.is_empty());
}
+42
View File
@@ -0,0 +1,42 @@
use dirigent_fermata::core::{Decision, Op, Reason, Rule};
#[test]
fn op_variants_exist() {
let _ = Op::Read;
let _ = Op::Write;
let _ = Op::Execute;
}
#[test]
fn decision_allow_is_simple() {
let d = Decision::Allow;
assert!(matches!(d, Decision::Allow));
}
#[test]
fn decision_deny_carries_reason() {
let rule = Rule {
source: "/proj/.botignore".into(),
pattern: ".env".into(),
};
let d = Decision::Deny(Reason {
message: "blocked by .botignore".into(),
rule: Some(rule),
});
match d {
Decision::Deny(r) => {
assert_eq!(r.message, "blocked by .botignore");
assert!(r.rule.is_some());
}
_ => panic!("expected Deny"),
}
}
#[test]
fn decision_ask_carries_reason() {
let d = Decision::Ask(Reason {
message: "needs confirmation".into(),
rule: None,
});
assert!(matches!(d, Decision::Ask(_)));
}
+52
View File
@@ -0,0 +1,52 @@
use dirigent_fermata::core::{Decision, Policy};
use std::fs;
use tempfile::TempDir;
fn project_with(toml: &str) -> TempDir {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("botignore.toml"), toml).unwrap();
tmp
}
#[test]
fn deny_substring_blocks() {
let tmp = project_with("[bash]\ndeny = [\"rm -rf /\"]\n");
let p = Policy::load(tmp.path()).unwrap();
assert!(matches!(p.check_command("sudo rm -rf / now").unwrap(), Decision::Deny(_)));
}
#[test]
fn deny_glob_blocks() {
let tmp = project_with("[bash]\ndeny = [\"git push --force*\"]\n");
let p = Policy::load(tmp.path()).unwrap();
assert!(matches!(p.check_command("git push --force-with-lease").unwrap(), Decision::Deny(_)));
}
#[test]
fn ask_returns_ask() {
let tmp = project_with("[bash]\nask = [\"rm *\"]\n");
let p = Policy::load(tmp.path()).unwrap();
assert!(matches!(p.check_command("rm somefile").unwrap(), Decision::Ask(_)));
}
#[test]
fn allow_prefixes_allows() {
let tmp = project_with("[bash]\nallow_prefixes = [\"make test\"]\n");
let p = Policy::load(tmp.path()).unwrap();
assert_eq!(p.check_command("make test").unwrap(), Decision::Allow);
assert_eq!(p.check_command("make test-unit").unwrap(), Decision::Allow);
}
#[test]
fn no_rules_means_allow() {
let tmp = project_with("");
let p = Policy::load(tmp.path()).unwrap();
assert_eq!(p.check_command("anything goes").unwrap(), Decision::Allow);
}
#[test]
fn deny_takes_precedence_over_allow_prefix() {
let tmp = project_with("[bash]\ndeny = [\"rm -rf /\"]\nallow_prefixes = [\"rm\"]\n");
let p = Policy::load(tmp.path()).unwrap();
assert!(matches!(p.check_command("rm -rf /").unwrap(), Decision::Deny(_)));
}
+64
View File
@@ -0,0 +1,64 @@
use dirigent_fermata::core::{Decision, Op, Policy};
use std::fs;
use tempfile::TempDir;
fn make_project(botignore: &str, toml_text: &str) -> TempDir {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join(".botignore"), botignore).unwrap();
if !toml_text.is_empty() {
fs::write(tmp.path().join("botignore.toml"), toml_text).unwrap();
}
tmp
}
#[test]
fn botignore_blocks_read() {
let tmp = make_project(".env\n", "");
let policy = Policy::load(tmp.path()).unwrap();
let target = tmp.path().join(".env");
fs::write(&target, "").unwrap();
let d = policy.check(Op::Read, &target).unwrap();
assert!(matches!(d, Decision::Deny(_)));
}
#[test]
fn botignore_blocks_write_too() {
let tmp = make_project(".env\n", "");
let policy = Policy::load(tmp.path()).unwrap();
let target = tmp.path().join(".env");
let d = policy.check(Op::Write, &target).unwrap();
assert!(matches!(d, Decision::Deny(_)));
}
#[test]
fn unmatched_path_allowed() {
let tmp = make_project(".env\n", "");
let policy = Policy::load(tmp.path()).unwrap();
let target = tmp.path().join("src/main.rs");
fs::create_dir_all(target.parent().unwrap()).unwrap();
fs::write(&target, "").unwrap();
let d = policy.check(Op::Read, &target).unwrap();
assert_eq!(d, Decision::Allow);
}
#[test]
fn toml_read_block_applies_only_to_read() {
let tmp = make_project("", "[read]\npatterns = [\"secrets/**\"]\n");
let policy = Policy::load(tmp.path()).unwrap();
let target = tmp.path().join("secrets/key.pem");
fs::create_dir_all(target.parent().unwrap()).unwrap();
fs::write(&target, "").unwrap();
assert!(matches!(policy.check(Op::Read, &target).unwrap(), Decision::Deny(_)));
assert_eq!(policy.check(Op::Write, &target).unwrap(), Decision::Allow);
}
#[test]
fn toml_write_block_applies_only_to_write() {
let tmp = make_project("", "[write]\npatterns = [\"vendor/**\"]\n");
let policy = Policy::load(tmp.path()).unwrap();
let target = tmp.path().join("vendor/lib.rs");
fs::create_dir_all(target.parent().unwrap()).unwrap();
fs::write(&target, "").unwrap();
assert_eq!(policy.check(Op::Read, &target).unwrap(), Decision::Allow);
assert!(matches!(policy.check(Op::Write, &target).unwrap(), Decision::Deny(_)));
}
+69
View File
@@ -0,0 +1,69 @@
use dirigent_fermata::core::project::find_project_root;
use std::fs;
use tempfile::TempDir;
#[test]
fn finds_botignore_toml_first() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join("sub/deep")).unwrap();
fs::write(root.join("botignore.toml"), "").unwrap();
fs::write(root.join(".botignore"), "").unwrap();
fs::create_dir_all(root.join(".git")).unwrap();
let target = root.join("sub/deep/file.rs");
fs::write(&target, "").unwrap();
let found = find_project_root(&target).unwrap();
assert_eq!(found, root);
}
#[test]
fn falls_back_to_botignore() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join("sub")).unwrap();
fs::write(root.join(".botignore"), "").unwrap();
let target = root.join("sub/file.rs");
fs::write(&target, "").unwrap();
let found = find_project_root(&target).unwrap();
assert_eq!(found, root);
}
#[test]
fn falls_back_to_git() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join("sub")).unwrap();
fs::create_dir_all(root.join(".git")).unwrap();
let target = root.join("sub/file.rs");
fs::write(&target, "").unwrap();
let found = find_project_root(&target).unwrap();
assert_eq!(found, root);
}
#[test]
fn returns_none_when_no_marker() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("file.rs");
std::fs::write(&target, "").unwrap();
assert!(find_project_root(&target).is_none());
}
#[test]
fn walks_up_from_file_path_not_cwd() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join("a/b/c")).unwrap();
fs::write(root.join("a/.botignore"), "").unwrap();
let target = root.join("a/b/c/file.rs");
fs::write(&target, "").unwrap();
let found = find_project_root(&target).unwrap();
assert_eq!(found, root.join("a"));
}
+47
View File
@@ -0,0 +1,47 @@
use dirigent_fermata::core::toml_config::{BotignoreToml, OpRules, BashRules};
#[test]
fn parses_full_config() {
let src = r#"
[read]
patterns = [".env*", "secrets/**"]
[write]
patterns = ["vendor/**", "*.lock"]
[bash]
deny = ["rm -rf /", "git push --force*"]
ask = ["rm:*"]
allow_prefixes = ["make test", "git checkout:*"]
"#;
let cfg: BotignoreToml = toml::from_str(src).unwrap();
assert_eq!(cfg.read.unwrap().patterns, vec![".env*", "secrets/**"]);
assert_eq!(cfg.write.unwrap().patterns, vec!["vendor/**", "*.lock"]);
let bash = cfg.bash.unwrap();
assert_eq!(bash.deny, vec!["rm -rf /", "git push --force*"]);
assert_eq!(bash.ask, vec!["rm:*"]);
assert_eq!(bash.allow_prefixes, vec!["make test", "git checkout:*"]);
}
#[test]
fn empty_config_is_valid() {
let cfg: BotignoreToml = toml::from_str("").unwrap();
assert!(cfg.read.is_none());
assert!(cfg.write.is_none());
assert!(cfg.bash.is_none());
}
#[test]
fn loads_from_disk_when_present() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("botignore.toml"), "[read]\npatterns = [\".env\"]\n").unwrap();
let cfg = BotignoreToml::load(tmp.path()).unwrap();
assert_eq!(cfg.read.unwrap().patterns, vec![".env"]);
}
#[test]
fn loads_empty_when_missing() {
let tmp = tempfile::tempdir().unwrap();
let cfg = BotignoreToml::load(tmp.path()).unwrap();
assert!(cfg.read.is_none());
}
+112
View File
@@ -0,0 +1,112 @@
//! Smoke-test contract from `docs/workpad/brainstorm/fermata.md` Appendix A.4.
use dirigent_fermata::core::{Decision, Op, Policy};
use std::fs;
use tempfile::TempDir;
fn fixture() -> TempDir {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(root.join(".botignore"), ".env\n.env.*\nconf/cert/**\nconf/mitmproxy/**\n").unwrap();
fs::write(
root.join("botignore.toml"),
r#"
[read]
patterns = [
"conf/localtestsettings.yaml",
"conf/localsettings.yaml",
"conf/default-secrets.yaml",
".claude/self-reflections/**",
]
[write]
patterns = [
"conf/localtestsettings.yaml",
"conf/localsettings.yaml",
"conf/default-secrets.yaml",
]
[bash]
deny = ["localtestsettings.yaml", "localsettings.yaml", "default-secrets.yaml", ".env"]
ask = ["rm *", "mv *"]
allow_prefixes = ["make test"]
"#,
)
.unwrap();
fs::create_dir_all(root.join("conf")).unwrap();
fs::create_dir_all(root.join("datatap")).unwrap();
fs::create_dir_all(root.join(".claude/self-reflections")).unwrap();
for f in [".env", "conf/localsettings.yaml", "datatap/foo.py", ".claude/self-reflections/x.md"] {
fs::write(root.join(f), "").unwrap();
}
tmp
}
#[test]
fn read_dot_env_denied() {
let t = fixture();
let p = Policy::load(t.path()).unwrap();
assert!(matches!(p.check(Op::Read, &t.path().join(".env")).unwrap(), Decision::Deny(_)));
}
#[test]
fn bash_cat_dot_env_denied() {
let t = fixture();
let p = Policy::load(t.path()).unwrap();
assert!(matches!(p.check_command("cat ./.env").unwrap(), Decision::Deny(_)));
}
#[test]
fn bash_rm_localsettings_denied() {
let t = fixture();
let p = Policy::load(t.path()).unwrap();
assert!(matches!(
p.check_command("rm ./conf/localsettings.yaml").unwrap(),
Decision::Deny(_)
));
}
#[test]
fn write_localsettings_denied() {
let t = fixture();
let p = Policy::load(t.path()).unwrap();
assert!(matches!(
p.check(Op::Write, &t.path().join("conf/localsettings.yaml")).unwrap(),
Decision::Deny(_)
));
}
#[test]
fn edit_datatap_foo_py_allowed() {
let t = fixture();
let p = Policy::load(t.path()).unwrap();
assert_eq!(
p.check(Op::Write, &t.path().join("datatap/foo.py")).unwrap(),
Decision::Allow
);
}
#[test]
fn bash_make_test_allowed() {
let t = fixture();
let p = Policy::load(t.path()).unwrap();
assert_eq!(p.check_command("make test").unwrap(), Decision::Allow);
}
#[test]
fn bash_rm_somefile_asks() {
let t = fixture();
let p = Policy::load(t.path()).unwrap();
assert!(matches!(p.check_command("rm somefile").unwrap(), Decision::Ask(_)));
}
#[test]
fn read_self_reflections_asks() {
// Note: A.4 has self-reflections under "ask" — current toml schema uses `[read].patterns`
// for hard reads. This documents the gap; once toml has a `[read].ask`, switch to Ask.
let t = fixture();
let p = Policy::load(t.path()).unwrap();
let d = p.check(Op::Read, &t.path().join(".claude/self-reflections/x.md")).unwrap();
assert!(matches!(d, Decision::Deny(_)));
}
+86
View File
@@ -0,0 +1,86 @@
use dirigent_fermata::core::{Decision, Reason};
use dirigent_fermata::harness::{HarnessAdapter, PathKind, ToolOp};
use dirigent_fermata::harness::claude::ClaudeAdapter;
#[test]
fn parses_read_payload() {
let payload = br#"{"tool_name":"Read","tool_input":{"file_path":"/proj/.env"}}"#;
let call = ClaudeAdapter.parse_request(payload).unwrap();
assert_eq!(call.tool_name, "Read");
match call.op {
ToolOp::Path { path, kind } => {
assert_eq!(path.to_string_lossy(), "/proj/.env");
assert_eq!(kind, PathKind::Read);
}
_ => panic!("expected Path op"),
}
}
#[test]
fn parses_write_payload() {
let payload = br#"{"tool_name":"Write","tool_input":{"file_path":"/proj/out.txt"}}"#;
let call = ClaudeAdapter.parse_request(payload).unwrap();
assert!(matches!(call.op, ToolOp::Path { kind: PathKind::Write, .. }));
}
#[test]
fn parses_edit_as_write() {
let payload = br#"{"tool_name":"Edit","tool_input":{"file_path":"/proj/src.rs"}}"#;
let call = ClaudeAdapter.parse_request(payload).unwrap();
assert!(matches!(call.op, ToolOp::Path { kind: PathKind::Write, .. }));
}
#[test]
fn parses_multiedit_as_write() {
let payload = br#"{"tool_name":"MultiEdit","tool_input":{"file_path":"/proj/src.rs","edits":[]}}"#;
let call = ClaudeAdapter.parse_request(payload).unwrap();
assert!(matches!(call.op, ToolOp::Path { kind: PathKind::Write, .. }));
}
#[test]
fn parses_bash_payload() {
let payload = br#"{"tool_name":"Bash","tool_input":{"command":"rm -rf /"}}"#;
let call = ClaudeAdapter.parse_request(payload).unwrap();
match call.op {
ToolOp::Command { text } => assert_eq!(text, "rm -rf /"),
_ => panic!("expected Command op"),
}
}
#[test]
fn renders_deny_as_hookspecificoutput() {
let payload = br#"{"tool_name":"Read","tool_input":{"file_path":"/proj/.env"}}"#;
let call = ClaudeAdapter.parse_request(payload).unwrap();
let d = Decision::Deny(Reason {
message: "blocked by .botignore: .env".into(),
rule: None,
});
let out = ClaudeAdapter.render_decision(&call, &d).unwrap();
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(v["hookSpecificOutput"]["hookEventName"], "PreToolUse");
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "deny");
assert!(v["hookSpecificOutput"]["permissionDecisionReason"]
.as_str()
.unwrap()
.contains(".env"));
}
#[test]
fn renders_allow_as_allow() {
let payload = br#"{"tool_name":"Read","tool_input":{"file_path":"/proj/src/main.rs"}}"#;
let call = ClaudeAdapter.parse_request(payload).unwrap();
let out = ClaudeAdapter.render_decision(&call, &Decision::Allow).unwrap();
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "allow");
}
#[test]
fn renders_ask_as_ask() {
let payload = br#"{"tool_name":"Bash","tool_input":{"command":"rm something"}}"#;
let call = ClaudeAdapter.parse_request(payload).unwrap();
let out = ClaudeAdapter
.render_decision(&call, &Decision::Ask(Reason { message: "confirm".into(), rule: None }))
.unwrap();
let v: serde_json::Value = serde_json::from_slice(&out).unwrap();
assert_eq!(v["hookSpecificOutput"]["permissionDecision"], "ask");
}