use super::*;
pub(super) fn looks_like_file_path(s: &str) -> bool {
if s.is_empty() || s.contains('\n') || s.contains('\r') || s == "." {
return false;
}
let trimmed = s.trim_start_matches(|c: char| c.is_ascii_digit());
if trimmed.starts_with('>') || trimmed.starts_with('<') || trimmed.starts_with('|') {
return false;
}
!contains_shell_variable(s) && !contains_unexpanded_glob(s)
}
fn contains_shell_variable(s: &str) -> bool {
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '$'
&& chars.next().is_some_and(|next| {
next == '{'
|| next.is_ascii_alphanumeric()
|| matches!(next, '_' | '?' | '#' | '@' | '*' | '!')
})
{
return true;
}
}
false
}
fn contains_unexpanded_glob(s: &str) -> bool {
s.contains('*') || s.contains('?')
}
pub(crate) fn extract_path(input: &serde_json::Value, variant: HookVariant) -> Option<String> {
match variant {
HookVariant::ClaudeConfigChange => None,
HookVariant::ClaudePreRead | HookVariant::ClaudePreEdit => {
input
.pointer("/tool_input/file_path")
.or_else(|| input.pointer("/tool_input/notebook_path"))
.or_else(|| input.pointer("/tool_input/path"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
}
HookVariant::ClaudePreBash | HookVariant::CodexPreBash | HookVariant::CodexPostBash => {
let cmd = input
.pointer("/tool_input/command")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())?;
let class = decide::classify_command(cmd)?;
decide::extract_file_path(cmd, class).filter(|p| looks_like_file_path(p))
}
HookVariant::CodexPreApplyPatch => None,
HookVariant::ClaudePostMemGet => None,
HookVariant::ClaudePostBash => None,
HookVariant::ClaudePostTask => None,
HookVariant::ClaudeInstructionsLoaded => None,
HookVariant::ClaudeFileChanged => None,
}
}
pub(crate) fn discover_repo_root(cwd: &Path) -> Option<PathBuf> {
discover_repo_root_for(&mati_core::store::RepoIdent::discover(cwd))
}
pub(crate) fn discover_repo_root_for(ident: &mati_core::store::RepoIdent) -> Option<PathBuf> {
let root = ident.workdir.as_ref()?.to_str()?;
Some(PathBuf::from(root))
}
pub(crate) fn canonical_rel_path(
raw_path: &str,
cwd: &Path,
repo_root: Option<&Path>,
lexical_rel: &str,
) -> Option<String> {
let repo_root = repo_root?;
let canon_root = super::sandbox::canonicalize_lenient(repo_root)?;
let raw = Path::new(raw_path);
let abs_access = if raw.is_absolute() {
raw.to_path_buf()
} else {
cwd.join(raw)
};
let canon_access = super::sandbox::canonicalize_lenient(&abs_access)?;
let stripped = canon_access.strip_prefix(&canon_root).ok()?;
let stripped_str = stripped.to_str()?;
let canon_rel = decide::normalize_path(stripped_str, None);
if canon_rel == lexical_rel {
return None;
}
Some(canon_rel)
}
pub(super) fn resolve_access_path(accessed_path: &str, cwd: &Path) -> PathBuf {
let raw = Path::new(accessed_path);
if raw.is_absolute() {
raw.to_path_buf()
} else {
cwd.join(raw)
}
}
pub(crate) fn file_exists_for_deleted_signal(
eval_data: &serde_json::Value,
accessed_path: &str,
cwd: &Path,
) -> Option<bool> {
let file_record = eval_data.get("file_record")?;
if !decide::has_file_deleted_signal(file_record) {
return None;
}
resolve_access_path(accessed_path, cwd).try_exists().ok()
}