use std::path::{Component, Path, PathBuf};
use crate::config::scope::{EnvLookup, SystemEnv};
use crate::facts::shell::{Bash, Redirect, RedirectOp};
use crate::hook_input::HookInput;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PathTool {
Read,
Edit,
Write,
ApplyPatch,
Mcp,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PathOrigin {
ToolInputDirect,
ToolInputNested,
ApplyPatch,
BashRedirect,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PathFact {
pub tool: PathTool,
pub raw: String,
pub expanded: PathBuf,
pub absolute: PathBuf,
pub canonical_or_raw: PathBuf,
pub origin: PathOrigin,
}
pub type FilePath = PathFact;
pub(crate) fn resolve_with_env(raw: &str, base_dir: Option<&Path>, env: &dyn EnvLookup) -> PathBuf {
let expanded = expand_home(raw, env);
if expanded.is_relative()
&& let Some(base) = base_dir
{
return base.join(expanded);
}
expanded
}
pub fn extract_all_with_env(input: &HookInput, env: &dyn EnvLookup) -> Vec<PathFact> {
let (tool, tagged): (PathTool, Vec<(String, PathOrigin)>) = match input.tool_name.as_str() {
"Read" | "Edit" | "Write" => {
let tool = match input.tool_name.as_str() {
"Read" => PathTool::Read,
"Edit" => PathTool::Edit,
_ => PathTool::Write,
};
let values = input
.tool_input
.get("file_path")
.and_then(serde_json::Value::as_str)
.map(|raw| (raw.to_owned(), PathOrigin::ToolInputDirect))
.into_iter()
.collect();
(tool, values)
},
"apply_patch" => {
let values = input
.tool_input
.get("command")
.and_then(serde_json::Value::as_str)
.map(collect_apply_patch_paths)
.unwrap_or_default()
.into_iter()
.map(|raw| (raw, PathOrigin::ApplyPatch))
.collect();
(PathTool::ApplyPatch, values)
},
_ if input.is_mcp_tool() => (PathTool::Mcp, collect_mcp_paths(&input.tool_input)),
_ => return Vec::new(),
};
tagged
.into_iter()
.map(|(raw, origin)| PathFact::from_raw(raw, tool, origin, None, env))
.collect()
}
pub fn extract_with_env(input: &HookInput, env: &dyn EnvLookup) -> Option<FilePath> {
extract_all_with_env(input, env).into_iter().next()
}
pub fn extract(input: &HookInput) -> Option<FilePath> {
extract_all_with_env(input, &SystemEnv).into_iter().next()
}
pub fn extract_all(input: &HookInput) -> Vec<FilePath> {
extract_all_with_env(input, &SystemEnv)
}
const MCP_DIRECT_PATH_KEYS: &[&str] = &[
"path",
"file_path",
"filename",
"file",
"filepath",
"target",
"target_file",
"dest",
"destination",
"src",
"source",
"from",
"to",
"location",
"uri",
];
fn collect_mcp_paths(value: &serde_json::Value) -> Vec<(String, PathOrigin)> {
let mut out = Vec::new();
for key in MCP_DIRECT_PATH_KEYS {
push_tagged(value.get(*key), PathOrigin::ToolInputDirect, &mut out);
}
for key in ["files", "items"] {
if let Some(items) = value.get(key).and_then(serde_json::Value::as_array) {
for item in items {
push_tagged(item.get("path"), PathOrigin::ToolInputNested, &mut out);
}
}
}
if let Some(paths) = value.get("paths").and_then(serde_json::Value::as_array) {
for item in paths {
push_tagged(Some(item), PathOrigin::ToolInputNested, &mut out);
}
}
out
}
fn push_tagged(
value: Option<&serde_json::Value>,
origin: PathOrigin,
out: &mut Vec<(String, PathOrigin)>,
) {
if let Some(s) = value.and_then(serde_json::Value::as_str) {
out.push((s.to_owned(), origin));
}
}
fn collect_apply_patch_paths(command: &str) -> Vec<String> {
let mut out = Vec::new();
for line in command.lines() {
for prefix in [
"*** Add File: ",
"*** Update File: ",
"*** Delete File: ",
"*** Move to: ",
] {
if let Some(path) = line.strip_prefix(prefix) {
let trimmed = path.trim();
if !trimmed.is_empty() {
out.push(trimmed.to_string());
}
break;
}
}
}
out
}
pub fn resolve_for_containment(fact: &PathFact) -> PathBuf {
if fact.canonical_or_raw != fact.absolute {
return fact.canonical_or_raw.clone();
}
climb_and_canonicalize(&fact.absolute)
}
pub fn is_within_workspace(target: &Path, workspaces: &[PathBuf]) -> bool {
workspaces.iter().any(|w| target.starts_with(w))
}
pub(crate) fn canonical_workspaces(
repo_root: Option<&Path>,
additional: &[String],
env: &dyn EnvLookup,
) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = Vec::new();
if let Some(root) = repo_root {
push_canonical(root.to_path_buf(), &mut out);
}
for raw in additional {
push_canonical(expand_home(raw, env), &mut out);
}
out
}
fn push_canonical(path: PathBuf, out: &mut Vec<PathBuf>) {
let resolved = path.canonicalize().unwrap_or(path);
if !out.contains(&resolved) {
out.push(resolved);
}
}
pub fn normalize_components(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for comp in path.components() {
match comp {
Component::CurDir => {},
Component::ParentDir => {
let _ = out.pop();
},
other => out.push(other.as_os_str()),
}
}
out
}
fn climb_and_canonicalize(abs: &Path) -> PathBuf {
let mut current = abs.to_path_buf();
let mut tail: Vec<std::ffi::OsString> = Vec::new();
loop {
if let Ok(canon) = current.canonicalize() {
let mut rebuilt = canon;
for name in tail.iter().rev() {
rebuilt.push(name);
}
return normalize_components(&rebuilt);
}
let Some(name) = current.file_name().map(std::ffi::OsStr::to_os_string) else {
break;
};
tail.push(name);
if !current.pop() {
break;
}
}
normalize_components(abs)
}
impl PathFact {
pub fn from_raw(
raw: String,
tool: PathTool,
origin: PathOrigin,
base_dir: Option<&Path>,
env: &dyn EnvLookup,
) -> Self {
let expanded = expand_home(&raw, env);
let absolute = resolve_with_env(&raw, base_dir, env);
let canonical_or_raw = absolute.canonicalize().unwrap_or_else(|_| absolute.clone());
Self {
tool,
raw,
expanded,
absolute,
canonical_or_raw,
origin,
}
}
}
pub fn from_bash_redirects(bash: Option<&Bash>, repo_root: Option<&Path>) -> Vec<PathFact> {
let Some(bash) = bash else {
return Vec::new();
};
let mut out = Vec::new();
collect_bash_redirects(bash, repo_root, &mut out);
out
}
fn collect_bash_redirects(bash: &Bash, repo_root: Option<&Path>, out: &mut Vec<PathFact>) {
for pipeline in &bash.segments {
for redirect in &pipeline.redirects {
push_redirect_fact(redirect, repo_root, out);
}
for command in &pipeline.commands {
collect_command_redirects(command, repo_root, out);
}
}
}
fn collect_command_redirects(
command: &crate::facts::shell::Argv,
repo_root: Option<&Path>,
out: &mut Vec<PathFact>,
) {
for redirect in &command.inner_redirects {
push_redirect_fact(redirect, repo_root, out);
}
for nested in &command.inner_argv {
collect_command_redirects(nested, repo_root, out);
}
}
fn push_redirect_fact(redirect: &Redirect, repo_root: Option<&Path>, out: &mut Vec<PathFact>) {
if matches!(redirect.op, RedirectOp::Heredoc) {
return;
}
if redirect.target.is_empty() {
return;
}
out.push(PathFact::from_raw(
redirect.target.clone(),
PathTool::Write,
PathOrigin::BashRedirect,
repo_root,
&SystemEnv,
));
}
fn expand_home(raw: &str, env: &dyn EnvLookup) -> PathBuf {
let Some(home_os) = env.var_os("HOME") else {
return PathBuf::from(raw);
};
let home = PathBuf::from(home_os);
if let Some(rest) = raw.strip_prefix("~/") {
return home.join(rest);
}
if raw == "~" {
return home;
}
if let Some(rest) = raw.strip_prefix("$HOME/") {
return home.join(rest);
}
if raw == "$HOME" {
return home;
}
if let Some(rest) = raw.strip_prefix("${HOME}/") {
return home.join(rest);
}
if raw == "${HOME}" {
return home;
}
PathBuf::from(raw)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::ffi::OsString;
struct MapEnv(HashMap<String, OsString>);
impl MapEnv {
fn with_home(home: &str) -> Self {
let mut m = HashMap::new();
m.insert("HOME".to_string(), OsString::from(home));
Self(m)
}
fn empty() -> Self {
Self(HashMap::new())
}
}
impl EnvLookup for MapEnv {
fn var_os(&self, key: &str) -> Option<OsString> {
self.0.get(key).cloned()
}
}
fn input(tool: &str, file_path: serde_json::Value) -> HookInput {
HookInput {
tool_name: tool.into(),
tool_input: serde_json::json!({ "file_path": file_path }),
}
}
#[test]
fn extracts_read_file_path() {
let i = input("Read", serde_json::json!("/tmp/x.txt"));
let fp = extract_with_env(&i, &MapEnv::with_home("/h")).unwrap();
assert_eq!(fp.tool, PathTool::Read);
assert_eq!(fp.raw, "/tmp/x.txt");
assert_eq!(fp.absolute, PathBuf::from("/tmp/x.txt"));
}
#[test]
fn extracts_edit_and_write() {
let e = input("Edit", serde_json::json!("/etc/hosts"));
let w = input("Write", serde_json::json!("/etc/hosts"));
assert_eq!(
extract_with_env(&e, &MapEnv::with_home("/h")).unwrap().tool,
PathTool::Edit
);
assert_eq!(
extract_with_env(&w, &MapEnv::with_home("/h")).unwrap().tool,
PathTool::Write
);
}
#[test]
fn returns_none_for_unknown_tool() {
let i = input("Bash", serde_json::json!("/x"));
assert!(extract_with_env(&i, &MapEnv::with_home("/h")).is_none());
}
#[test]
fn returns_none_when_field_missing_or_non_string() {
let i = HookInput {
tool_name: "Read".into(),
tool_input: serde_json::json!({}),
};
assert!(extract_with_env(&i, &MapEnv::with_home("/h")).is_none());
let j = input("Read", serde_json::json!(123));
assert!(extract_with_env(&j, &MapEnv::with_home("/h")).is_none());
}
#[test]
fn expands_tilde_to_home() {
let i = input("Read", serde_json::json!("~/.ssh/id_rsa"));
let fp = extract_with_env(&i, &MapEnv::with_home("/home/me")).unwrap();
assert_eq!(fp.absolute, PathBuf::from("/home/me/.ssh/id_rsa"));
}
#[test]
fn expands_lone_tilde() {
let i = input("Read", serde_json::json!("~"));
let fp = extract_with_env(&i, &MapEnv::with_home("/home/me")).unwrap();
assert_eq!(fp.absolute, PathBuf::from("/home/me"));
}
#[test]
fn expands_home_envvar_forms() {
for raw in ["$HOME/.ssh/id", "${HOME}/.ssh/id"] {
let i = input("Read", serde_json::json!(raw));
let fp = extract_with_env(&i, &MapEnv::with_home("/h")).unwrap();
assert_eq!(fp.absolute, PathBuf::from("/h/.ssh/id"));
}
}
#[test]
fn lone_home_envvar_expands() {
for raw in ["$HOME", "${HOME}"] {
let i = input("Read", serde_json::json!(raw));
let fp = extract_with_env(&i, &MapEnv::with_home("/h")).unwrap();
assert_eq!(fp.absolute, PathBuf::from("/h"));
}
}
#[test]
fn falls_back_to_raw_when_home_unset() {
let i = input("Read", serde_json::json!("~/.ssh/id"));
let fp = extract_with_env(&i, &MapEnv::empty()).unwrap();
assert_eq!(fp.absolute, PathBuf::from("~/.ssh/id"));
}
#[test]
fn path_fact_canonical_falls_back_to_absolute_when_missing() {
let i = input(
"Read",
serde_json::json!("/ptuf-nonexistent-path/most-likely-missing"),
);
let fp = extract_with_env(&i, &MapEnv::with_home("/h")).expect("path");
assert_eq!(fp.canonical_or_raw, fp.absolute);
assert_eq!(
fp.canonical_or_raw,
PathBuf::from("/ptuf-nonexistent-path/most-likely-missing")
);
}
#[test]
fn path_fact_origin_distinguishes_apply_patch_from_direct() {
let direct = input("Edit", serde_json::json!("/tmp/x"));
let direct_fp = extract_with_env(&direct, &MapEnv::with_home("/h")).expect("path");
assert_eq!(direct_fp.origin, PathOrigin::ToolInputDirect);
let patched = HookInput {
tool_name: "apply_patch".into(),
tool_input: serde_json::json!({
"command": "*** Begin Patch\n*** Add File: x\n*** End Patch\n"
}),
};
let patch_fp = extract_with_env(&patched, &MapEnv::with_home("/h")).expect("path");
assert_eq!(patch_fp.origin, PathOrigin::ApplyPatch);
}
#[test]
fn path_fact_mcp_nested_origin_is_nested() {
let i = HookInput {
tool_name: "mcp__github__push_files".into(),
tool_input: serde_json::json!({
"files": [{"path": "/tmp/nested"}],
}),
};
let paths = extract_all_with_env(&i, &MapEnv::with_home("/h"));
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].origin, PathOrigin::ToolInputNested);
}
#[test]
fn extract_uses_system_env_lookup() {
let i = input("Read", serde_json::json!("/tmp/sample"));
let fp = extract(&i).unwrap();
assert_eq!(fp.raw, "/tmp/sample");
}
#[test]
fn extract_mcp_tool_uses_top_level_path_key() {
let i = HookInput {
tool_name: "mcp__github__create_or_update_file".into(),
tool_input: serde_json::json!({"path": ".claude/settings.json"}),
};
let fp = extract_with_env(&i, &MapEnv::with_home("/h")).expect("path");
assert_eq!(fp.tool, PathTool::Mcp);
assert_eq!(fp.raw, ".claude/settings.json");
}
#[test]
fn extract_all_collects_nested_mcp_paths() {
let i = HookInput {
tool_name: "mcp__github__push_files".into(),
tool_input: serde_json::json!({
"files": [{"path": "~/.claude/settings.json"}, {"path": "/tmp/a"}],
"items": [{"path": "/tmp/b"}],
"paths": ["/tmp/c"]
}),
};
let paths = extract_all_with_env(&i, &MapEnv::with_home("/h"));
let raws: Vec<_> = paths.iter().map(|p| p.raw.as_str()).collect();
assert_eq!(
raws,
vec!["~/.claude/settings.json", "/tmp/a", "/tmp/b", "/tmp/c"]
);
}
#[test]
fn extract_mcp_tool_returns_none_when_path_missing() {
let i = HookInput {
tool_name: "mcp__filesystem__write_file".into(),
tool_input: serde_json::json!({"content": "hi"}),
};
assert!(extract_with_env(&i, &MapEnv::with_home("/h")).is_none());
}
#[test]
fn extract_mcp_tool_returns_none_when_path_is_not_string() {
let i = HookInput {
tool_name: "mcp__filesystem__write_file".into(),
tool_input: serde_json::json!({"path": 7}),
};
assert!(extract_with_env(&i, &MapEnv::with_home("/h")).is_none());
}
#[test]
fn extract_apply_patch_collects_add_update_delete_and_move_paths() {
let i = HookInput {
tool_name: "apply_patch".into(),
tool_input: serde_json::json!({
"command": "\
*** Begin Patch\n\
*** Add File: new.txt\n\
*** Update File: old.txt\n\
*** Move to: renamed.txt\n\
*** Delete File: gone.txt\n\
*** End Patch\n"
}),
};
let paths = extract_all_with_env(&i, &MapEnv::with_home("/h"));
let raws: Vec<_> = paths.iter().map(|p| p.raw.as_str()).collect();
assert_eq!(raws, vec!["new.txt", "old.txt", "renamed.txt", "gone.txt"]);
assert!(paths.iter().all(|p| p.tool == PathTool::ApplyPatch));
}
#[test]
fn extract_apply_patch_ignores_malformed_lines() {
let i = HookInput {
tool_name: "apply_patch".into(),
tool_input: serde_json::json!({
"command": "*** Begin Patch\n*** Update File:\n*** Move to: \n*** End Patch\n"
}),
};
assert!(extract_all_with_env(&i, &MapEnv::with_home("/h")).is_empty());
}
#[test]
fn extract_mcp_tool_recognises_synonym_keys() {
for key in [
"path",
"file_path",
"filename",
"file",
"filepath",
"target",
"target_file",
"dest",
"destination",
"src",
"source",
"from",
"to",
"location",
"uri",
] {
let i = HookInput {
tool_name: "mcp__custom__write".into(),
tool_input: serde_json::json!({ key: ".env" }),
};
let fp = extract_with_env(&i, &MapEnv::with_home("/h"))
.unwrap_or_else(|| panic!("expected PathFact for key {key:?}"));
assert_eq!(fp.raw, ".env", "key={key:?}");
assert_eq!(fp.tool, PathTool::Mcp);
assert_eq!(fp.origin, PathOrigin::ToolInputDirect);
}
}
#[test]
fn extract_mcp_tool_url_key_is_not_treated_as_path() {
let i = HookInput {
tool_name: "mcp__fetch__fetch".into(),
tool_input: serde_json::json!({"url": "https://example.com/x"}),
};
assert!(extract_with_env(&i, &MapEnv::with_home("/h")).is_none());
}
#[test]
fn extract_mcp_tool_expands_home_in_raw_path() {
let i = HookInput {
tool_name: "mcp__filesystem__read_file".into(),
tool_input: serde_json::json!({"path": "~/.aws/credentials"}),
};
let fp = extract_with_env(&i, &MapEnv::with_home("/home/me")).expect("path");
assert_eq!(fp.absolute, PathBuf::from("/home/me/.aws/credentials"));
}
#[test]
fn from_bash_redirects_returns_empty_for_none_bash() {
assert!(from_bash_redirects(None, None).is_empty());
}
#[test]
fn from_bash_redirects_returns_empty_when_no_redirects() {
let bash = crate::facts::shell::parse("ls -la");
assert!(from_bash_redirects(Some(&bash), None).is_empty());
}
#[test]
fn from_bash_redirects_skips_heredoc_target() {
let bash = crate::facts::shell::parse("cat <<EOF\nhello\nEOF\n");
assert!(from_bash_redirects(Some(&bash), None).is_empty());
}
#[test]
fn from_bash_redirects_emits_for_each_redirect_op() {
for cmd in [
"echo hi > out.txt",
"echo hi >> out.txt",
"sh < script.sh",
"cmd 2> err.log",
"cmd &> all.log",
] {
let bash = crate::facts::shell::parse(cmd);
let facts = from_bash_redirects(Some(&bash), None);
assert_eq!(facts.len(), 1, "expected one fact for {cmd:?}");
assert_eq!(facts[0].tool, PathTool::Write);
assert_eq!(facts[0].origin, PathOrigin::BashRedirect);
}
}
#[test]
fn from_bash_redirects_resolves_relative_against_repo_root() {
let bash = crate::facts::shell::parse("echo y > .claude/settings.json");
let facts = from_bash_redirects(Some(&bash), Some(Path::new("/repo")));
assert_eq!(facts.len(), 1);
assert_eq!(
facts[0].absolute,
PathBuf::from("/repo/.claude/settings.json")
);
assert_eq!(facts[0].raw, ".claude/settings.json");
}
#[test]
fn normalize_components_resolves_dot_and_dotdot() {
let p = PathBuf::from("/a/b/./c/../d");
assert_eq!(normalize_components(&p), PathBuf::from("/a/b/d"));
}
#[test]
fn normalize_components_dotdot_at_root_is_noop() {
let p = PathBuf::from("/../etc/passwd");
assert_eq!(normalize_components(&p), PathBuf::from("/etc/passwd"));
}
#[test]
fn normalize_components_preserves_relative() {
let p = PathBuf::from("a/./b/../c");
assert_eq!(normalize_components(&p), PathBuf::from("a/c"));
}
#[test]
fn is_within_workspace_uses_component_prefix_not_byte_prefix() {
let ws = vec![PathBuf::from("/work")];
assert!(is_within_workspace(Path::new("/work/src/x.rs"), &ws));
assert!(is_within_workspace(Path::new("/work"), &ws));
assert!(!is_within_workspace(Path::new("/work-evil/secret"), &ws));
assert!(!is_within_workspace(Path::new("/etc/passwd"), &ws));
}
#[test]
fn is_within_workspace_empty_list_rejects_everything() {
assert!(!is_within_workspace(Path::new("/work/x"), &[]));
}
#[test]
fn resolve_for_containment_uses_canonical_when_available() {
let dir = std::env::temp_dir().join(format!(
"ptuf-resolve-canon-{}-{}",
std::process::id(),
line!()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("mkdir");
let real = dir.join("real.txt");
std::fs::write(&real, "hi").expect("write");
let fact = PathFact::from_raw(
real.to_string_lossy().into_owned(),
PathTool::Read,
PathOrigin::ToolInputDirect,
None,
&MapEnv::with_home("/h"),
);
let resolved = resolve_for_containment(&fact);
assert_eq!(resolved, real.canonicalize().expect("canon"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn resolve_for_containment_climbs_for_nonexistent_tail() {
let dir = std::env::temp_dir().join(format!(
"ptuf-resolve-climb-{}-{}",
std::process::id(),
line!()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("mkdir");
let target = dir.join("does/not/exist.txt");
let fact = PathFact::from_raw(
target.to_string_lossy().into_owned(),
PathTool::Write,
PathOrigin::ToolInputDirect,
None,
&MapEnv::with_home("/h"),
);
let resolved = resolve_for_containment(&fact);
let expected = dir
.canonicalize()
.expect("canon")
.join("does/not/exist.txt");
assert_eq!(resolved, expected);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn resolve_for_containment_resolves_dotdot_in_nonexistent_tail() {
let fact = PathFact {
tool: PathTool::Read,
raw: "/ptuf-nonexist/work/../etc/passwd".into(),
expanded: PathBuf::from("/ptuf-nonexist/work/../etc/passwd"),
absolute: PathBuf::from("/ptuf-nonexist/work/../etc/passwd"),
canonical_or_raw: PathBuf::from("/ptuf-nonexist/work/../etc/passwd"),
origin: PathOrigin::ToolInputDirect,
};
let resolved = resolve_for_containment(&fact);
assert_eq!(resolved, PathBuf::from("/ptuf-nonexist/etc/passwd"));
}
use crate::testing::proptest::{file_path, richer_hook_input};
use proptest::prelude::*;
proptest! {
#[test]
fn pbt_extract_never_panics(i in richer_hook_input()) {
let _ = extract_with_env(&i, &MapEnv::with_home("/h"));
let _ = extract(&i);
}
#[test]
fn pbt_non_path_tool_yields_none(
tool in "[A-Z][A-Za-z]{0,8}",
fp in file_path(),
) {
prop_assume!(!matches!(tool.as_str(), "Read" | "Edit" | "Write"));
let i = HookInput {
tool_name: tool,
tool_input: serde_json::json!({ "file_path": fp }),
};
prop_assert!(extract_with_env(&i, &MapEnv::with_home("/h")).is_none());
}
#[test]
fn pbt_path_tool_round_trips_raw(
tool in proptest::sample::select(&["Read", "Edit", "Write"][..]),
fp in file_path(),
) {
let i = HookInput {
tool_name: tool.to_string(),
tool_input: serde_json::json!({ "file_path": fp.clone() }),
};
let out = extract_with_env(&i, &MapEnv::with_home("/h")).expect("path");
prop_assert_eq!(out.raw, fp);
}
#[test]
fn pbt_absolute_paths_are_identity(
tool in proptest::sample::select(&["Read", "Edit", "Write"][..]),
tail in "[a-zA-Z0-9_./-]{0,20}",
) {
let raw = format!("/{tail}");
let i = HookInput {
tool_name: tool.to_string(),
tool_input: serde_json::json!({ "file_path": raw.clone() }),
};
let out = extract_with_env(&i, &MapEnv::with_home("/h")).expect("path");
prop_assert_eq!(out.absolute, PathBuf::from(raw));
}
#[test]
fn pbt_tilde_prefix_expands_to_home(
tail in "[a-zA-Z0-9_./-]{0,20}",
home in "/(?:home|h)/[a-z0-9_]{1,8}",
) {
let raw = format!("~/{tail}");
let i = HookInput {
tool_name: "Read".into(),
tool_input: serde_json::json!({ "file_path": raw }),
};
let env = MapEnv::with_home(&home);
let out = extract_with_env(&i, &env).expect("path");
prop_assert_eq!(out.absolute, PathBuf::from(home).join(tail));
}
#[test]
fn pbt_no_home_falls_back_to_raw(tail in "[a-zA-Z0-9_./-]{0,20}") {
let raw = format!("~/{tail}");
let i = HookInput {
tool_name: "Read".into(),
tool_input: serde_json::json!({ "file_path": raw.clone() }),
};
let out = extract_with_env(&i, &MapEnv::empty()).expect("path");
prop_assert_eq!(out.absolute, PathBuf::from(raw));
}
}
}