use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use crate::core::doctor::{CheckStatus, DoctorCheck};
use crate::core::standalone::hooks::cleanup::{
build_tree_hook_commands, build_tree_statusline_command, foreign_hook_event_names,
tm_hook_event_names,
};
pub(crate) 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()
}
pub(crate) 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, DoctorCheck, DoctorCheck) {
check_hooks_hygiene_with_exe(project_dir, active_workspace_paths, None)
}
pub(super) fn check_hooks_hygiene_with_exe(
project_dir: Option<&Path>,
active_workspace_paths: &[PathBuf],
hook_exe: Option<&Path>,
) -> (DoctorCheck, DoctorCheck, 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();
let mut build_tree: Vec<(PathBuf, String)> = Vec::new();
let mut missing: Vec<(PathBuf, Vec<String>)> = Vec::new();
for path in &files {
let Some(val) = read_settings(path) else {
continue;
};
let mut gaps = crate::core::session_launch::missing_lifecycle_hook_events(&val);
gaps.extend(toggle_driven_gaps(path, hook_exe));
if !gaps.is_empty() {
missing.push((path.clone(), gaps));
}
if !tm_hook_event_names(&val).is_empty() {
contaminated.push(path.clone());
}
if !foreign_hook_event_names(&val).is_empty() {
foreign.push(path.clone());
}
for cmd in build_tree_hook_commands(&val) {
build_tree.push((path.clone(), cmd));
}
if let Some(cmd) = build_tree_statusline_command(&val) {
build_tree.push((path.clone(), cmd));
}
}
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 — `tm doctor --fix` \
previews the removal for this project and `--yes` applies it; \
`tm hooks clean` sweeps every project under $HOME: {}",
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,
build_tree_check(&build_tree),
missing_group_check(&missing),
)
}
fn toggle_driven_gaps(settings_path: &Path, hook_exe: Option<&Path>) -> Vec<String> {
let Some(project_dir) = settings_path.parent().and_then(Path::parent) else {
return Vec::new();
};
let Some(val) = read_settings(settings_path) else {
return Vec::new();
};
let fw = crate::core::paths::FrameworkPaths::for_managed_workspace(project_dir);
let Ok(gaps) =
crate::core::session_launch::project_hook_group_gaps(&fw, project_dir, &val, hook_exe)
else {
return Vec::new();
};
if gaps.is_empty() {
return Vec::new();
}
gaps.missing
.into_iter()
.map(|(event, command)| format!("{event} (asks for `{command}`)"))
.collect()
}
fn missing_group_check(gaps: &[(PathBuf, Vec<String>)]) -> DoctorCheck {
if gaps.is_empty() {
return DoctorCheck::new(
"hooks_missing_tm_group",
CheckStatus::Ok,
"every tm-provisioned settings file carries the full tm hook lifecycle group",
);
}
let shown: Vec<String> = gaps
.iter()
.take(5)
.map(|(path, events)| format!("{}: {}", path.display(), events.join(", ")))
.collect();
let overflow = if gaps.len() > 5 {
format!(" (+{} more)", gaps.len() - 5)
} else {
String::new()
};
DoctorCheck::new(
"hooks_missing_tm_group",
CheckStatus::Warn,
format!(
"{} tm-provisioned settings file{} lack{} a tm hook group this project asks \
for, so `tm hook` never fires for it — a missing `SessionStart` costs the \
session its savings row and the 💸 statusline segment, and a missing \
`Stop (asks for … --prompt-feedback)` costs it every prompt-feedback row \
(#7849). `tm doctor --fix` previews merging the groups back in and `--yes` \
applies it, preserving every other entry (issue #7490); \
`tm validate --repair --path <project>` resyncs one project in place. {}{}",
gaps.len(),
if gaps.len() == 1 { "" } else { "s" },
if gaps.len() == 1 { "s" } else { "" },
shown.join("; "),
overflow,
),
)
}
fn build_tree_check(offenders: &[(PathBuf, String)]) -> DoctorCheck {
if offenders.is_empty() {
return DoctorCheck::new(
"hooks_build_tree_binary",
CheckStatus::Ok,
"no hook or statusLine command points into a Cargo build tree",
);
}
let shown: Vec<String> = offenders
.iter()
.take(5)
.map(|(path, cmd)| format!("{}: `{cmd}`", path.display()))
.collect();
let overflow = if offenders.len() > 5 {
format!(" (+{} more)", offenders.len() - 5)
} else {
String::new()
};
DoctorCheck::new(
"hooks_build_tree_binary",
CheckStatus::Warn,
format!(
"{} command{} point into a Cargo build tree and stop working the moment that \
artifact is rebuilt away — `tm doctor --fix` previews repointing every one of \
them at the installed tm binary and `--yes` applies it, snapshotting each file \
first. That repair sweeps every settings file on the machine, not just this \
project, and repairs a `statusLine` command the same way (#7262). {}{}",
offenders.len(),
if offenders.len() == 1 { "" } else { "s" },
shown.join("; "),
overflow,
),
)
}
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;