use std::collections::BTreeSet;
use std::path::Path;
use anyhow::Result;
use mati_core::store::{GotchaRecord, RecordLifecycle};
use super::hook_decide::{discover_repo_root, HookRunOutcome};
use super::hooks::run_with_deadline;
use super::proxy::StoreProxy;
const MAX_WATCH_PATHS: usize = 512;
pub async fn run_watch_paths() -> Result<HookRunOutcome> {
const WATCH_PATHS_DEADLINE_MS: u64 = 3000;
run_with_deadline(
"session-start",
"<watch-paths>",
WATCH_PATHS_DEADLINE_MS,
"watch path collection exceeded internal deadline",
run_watch_paths_inner(),
)
.await
}
async fn run_watch_paths_inner() -> Result<()> {
let cwd = match std::env::current_dir() {
Ok(d) => d,
Err(_) => return Ok(()),
};
let repo_root = discover_repo_root(&cwd).unwrap_or_else(|| cwd.clone());
let store = match StoreProxy::open(&cwd).await {
Ok(s) => s,
Err(_) => return Ok(()),
};
let gotchas = match store.scan_prefix("gotcha:").await {
Ok(g) => g,
Err(_) => return Ok(()),
};
let paths = watch_paths_for(&gotchas, &repo_root);
if paths.is_empty() {
return Ok(());
}
let out = serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"watchPaths": paths,
}
});
println!("{}", serde_json::to_string(&out)?);
Ok(())
}
fn is_repo_relative(rel: &str) -> bool {
use std::path::Component;
let path = Path::new(rel);
path.is_relative()
&& !path
.components()
.any(|c| matches!(c, Component::ParentDir | Component::Prefix(_)))
}
fn watch_paths_for(gotcha_records: &[mati_core::store::Record], repo_root: &Path) -> Vec<String> {
let mut paths: BTreeSet<String> = BTreeSet::new();
for record in gotcha_records {
if !matches!(record.lifecycle, RecordLifecycle::Active) {
continue;
}
let Some(gotcha) = record.payload_as::<GotchaRecord>() else {
continue;
};
for rel in &gotcha.affected_files {
if !is_repo_relative(rel) {
continue;
}
let abs = repo_root.join(rel);
if !abs.is_file() {
continue;
}
if let Some(s) = abs.to_str() {
paths.insert(s.to_string());
}
}
}
let total = paths.len();
let mut paths: Vec<String> = paths.into_iter().collect();
if total > MAX_WATCH_PATHS {
paths.truncate(MAX_WATCH_PATHS);
tracing::warn!(
"watch-paths: {total} gotcha'd files exceeds the {MAX_WATCH_PATHS} watch cap — \
{} file(s) will not report changes",
total - MAX_WATCH_PATHS
);
}
paths
}
#[cfg(test)]
mod tests {
use super::*;
use mati_core::store::{
Category, ConfidenceScore, Priority, QualityScore, Record, RecordSource, RecordVersion,
StalenessScore, TombstoneReason,
};
fn gotcha(key: &str, files: &[&str]) -> Record {
let payload = GotchaRecord {
rule: "Call close() before drop".into(),
reason: "because the lock leaks otherwise".into(),
severity: Priority::High,
affected_files: files.iter().map(|s| s.to_string()).collect(),
ref_url: None,
discovered_session: 1_000_000,
confirmed: true,
confirmed_content: Default::default(),
};
Record {
key: key.to_string(),
value: payload.rule.clone(),
payload: serde_json::to_value(&payload).ok(),
category: Category::Gotcha,
priority: Priority::High,
tags: vec![],
created_at: 1_000_000,
updated_at: 1_000_000,
ref_url: None,
staleness: StalenessScore::fresh(),
lifecycle: RecordLifecycle::Active,
version: RecordVersion {
device_id: uuid::Uuid::new_v4(),
logical_clock: 1,
wall_clock: 1_000_000,
},
quality: QualityScore::layer0_default(),
access_count: 0,
last_accessed: 0,
source: RecordSource::DeveloperManual,
confidence: ConfidenceScore::for_new_record(&RecordSource::DeveloperManual),
gap_analysis_score: 0.0,
}
}
fn repo_with(files: &[&str]) -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
for f in files {
let p = dir.path().join(f);
std::fs::create_dir_all(p.parent().expect("parent")).expect("mkdir");
std::fs::write(&p, "content").expect("write");
}
dir
}
#[test]
fn emits_absolute_paths_for_existing_gotcha_files() {
let dir = repo_with(&["src/a.rs"]);
let paths = watch_paths_for(&[gotcha("gotcha:a", &["src/a.rs"])], dir.path());
assert_eq!(paths, vec![format!("{}/src/a.rs", dir.path().display())]);
}
#[test]
fn skips_globs_and_missing_files() {
let dir = repo_with(&["src/a.rs"]);
let paths = watch_paths_for(
&[gotcha(
"gotcha:a",
&["src/a.rs", "src/payments/**", "src/gone.rs"],
)],
dir.path(),
);
assert_eq!(paths.len(), 1);
assert!(paths[0].ends_with("/src/a.rs"));
}
#[test]
fn deduplicates_files_named_by_several_gotchas() {
let dir = repo_with(&["src/a.rs", "src/b.rs"]);
let paths = watch_paths_for(
&[
gotcha("gotcha:a", &["src/a.rs"]),
gotcha("gotcha:b", &["src/a.rs", "src/b.rs"]),
],
dir.path(),
);
assert_eq!(paths.len(), 2);
}
#[test]
fn tombstoned_gotchas_contribute_nothing() {
let dir = repo_with(&["src/a.rs"]);
let mut g = gotcha("gotcha:a", &["src/a.rs"]);
g.lifecycle = RecordLifecycle::Tombstoned {
reason: TombstoneReason::ManualDeletion,
at: 1_000_100,
};
assert!(watch_paths_for(&[g], dir.path()).is_empty());
}
#[test]
fn skips_paths_that_escape_the_repo() {
let dir = repo_with(&["src/a.rs", "outside.rs"]);
let paths = watch_paths_for(
&[gotcha("gotcha:a", &["../outside.rs"])],
&dir.path().join("src"),
);
assert!(
paths.is_empty(),
"an out-of-repo path must not be watched, got {paths:?}"
);
}
#[test]
fn skips_absolute_affected_files() {
let dir = repo_with(&["src/a.rs"]);
let abs = dir.path().join("src/a.rs").display().to_string();
assert!(watch_paths_for(&[gotcha("gotcha:a", &[&abs])], dir.path()).is_empty());
}
}