use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use crate::core::doctor::{CheckStatus, DoctorCheck};
use crate::core::standalone::hooks::cleanup::{foreign_hook_event_names, tm_hook_event_names};
fn candidate_settings_files(
project_dir: Option<&Path>,
active_workspace_paths: &[PathBuf],
) -> Vec<PathBuf> {
let mut set: BTreeSet<PathBuf> = BTreeSet::new();
let roots = project_dir
.into_iter()
.chain(active_workspace_paths.iter().map(PathBuf::as_path));
for root in roots {
for name in ["settings.json", "settings.local.json"] {
let candidate = root.join(".claude").join(name);
if candidate.is_file() {
set.insert(candidate);
}
}
}
set.into_iter().collect()
}
fn read_settings(path: &Path) -> Option<serde_json::Value> {
let text = std::fs::read_to_string(path).ok()?;
let val: serde_json::Value = serde_json::from_str(&text).ok()?;
val.is_object().then_some(val)
}
pub(super) fn check_hooks_hygiene(
project_dir: Option<&Path>,
active_workspace_paths: &[PathBuf],
) -> (DoctorCheck, DoctorCheck) {
let files = candidate_settings_files(project_dir, active_workspace_paths);
let mut contaminated: Vec<PathBuf> = Vec::new();
let mut foreign: Vec<PathBuf> = Vec::new();
for path in &files {
let Some(val) = read_settings(path) else {
continue;
};
if !tm_hook_event_names(&val).is_empty() {
contaminated.push(path.clone());
}
if !foreign_hook_event_names(&val).is_empty() {
foreign.push(path.clone());
}
}
let contamination_check = if contaminated.is_empty() {
DoctorCheck::new(
"hooks_contamination",
CheckStatus::Ok,
"no tm hook entries found in project-level settings files",
)
} else {
DoctorCheck::new(
"hooks_contamination",
CheckStatus::Warn,
format!(
"{} project settings file{} carr{} tm hook entries — run `tm hooks clean` \
(dry run first) to remove them: {}",
contaminated.len(),
if contaminated.len() == 1 { "" } else { "s" },
if contaminated.len() == 1 { "ies" } else { "y" },
summarize_paths(&contaminated),
),
)
};
let foreign_check = if foreign.is_empty() {
DoctorCheck::new(
"hooks_foreign_conflict",
CheckStatus::Ok,
"no foreign (claude-mpm) hook entries found in project-level settings files",
)
} else {
DoctorCheck::new(
"hooks_foreign_conflict",
CheckStatus::Warn,
format!(
"{} project settings file{} carr{} claude-mpm hook entries that would fire \
inside a tm session — this is informational only, tm never removes another \
harness's hooks: {}",
foreign.len(),
if foreign.len() == 1 { "" } else { "s" },
if foreign.len() == 1 { "ies" } else { "y" },
summarize_paths(&foreign),
),
)
};
(contamination_check, foreign_check)
}
fn summarize_paths(paths: &[PathBuf]) -> String {
let shown: Vec<String> = paths
.iter()
.take(3)
.map(|p| p.display().to_string())
.collect();
if paths.len() > 3 {
format!("{} (+{} more)", shown.join(", "), paths.len() - 3)
} else {
shown.join(", ")
}
}
#[cfg(test)]
#[path = "doctor_hooks_hygiene_tests.rs"]
mod tests;