use std::collections::HashSet;
use std::path::Path;
use crate::core::bundle::OUTPUT_STYLES;
use crate::core::doctor::{CheckStatus, DoctorCheck};
enum StyleKey {
Present(String),
Silent,
}
fn read_style_key(path: &Path) -> Result<StyleKey, DoctorCheck> {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(StyleKey::Silent),
Err(e) => {
return Err(DoctorCheck::new(
"output_style",
CheckStatus::Fail,
format!("{} unreadable: {e}", path.display()),
));
}
};
let value: serde_json::Value = serde_json::from_str(&text).map_err(|e| {
DoctorCheck::new(
"output_style",
CheckStatus::Fail,
format!("{} is not valid JSON: {e}", path.display()),
)
})?;
match value.get("outputStyle").and_then(|v| v.as_str()) {
Some(id) => Ok(StyleKey::Present(id.to_string())),
None => Ok(StyleKey::Silent),
}
}
pub(crate) fn check_output_style(project_dir: Option<&Path>, home: &Path) -> DoctorCheck {
let global_path = home.join(".claude").join("settings.json");
if let Some(dir) = project_dir {
let project_path = dir.join(".claude").join("settings.json");
match read_style_key(&project_path) {
Ok(StyleKey::Present(style_id)) => {
return evaluate_style(&style_id, home, &project_path, "project");
}
Ok(StyleKey::Silent) => { }
Err(check) => return check,
}
}
match read_style_key(&global_path) {
Ok(StyleKey::Present(style_id)) => evaluate_style(&style_id, home, &global_path, "global"),
Ok(StyleKey::Silent) => DoctorCheck::new(
"output_style",
CheckStatus::Warn,
format!(
"no outputStyle configured (checked project and {}) — \
Claude Code will use its own default",
global_path.display()
),
),
Err(check) => check,
}
}
fn evaluate_style(style_id: &str, home: &Path, source_path: &Path, source: &str) -> DoctorCheck {
let known_ids: Vec<&str> = OUTPUT_STYLES.iter().map(|s| s.id).collect();
let Some(style) = OUTPUT_STYLES.iter().find(|s| s.id == style_id) else {
return DoctorCheck::new(
"output_style",
CheckStatus::Fail,
format!(
"{source} outputStyle {style_id:?} ({}) is not a known trusty-mpm style \
(valid: {}) — run `tm run`/`tm load` to rewrite it correctly",
source_path.display(),
known_ids.join(", ")
),
);
};
let style_path = home
.join(".claude")
.join("output-styles")
.join(style.file_name);
match std::fs::metadata(&style_path) {
Ok(meta) if meta.len() > 0 => DoctorCheck::new(
"output_style",
CheckStatus::Ok,
format!(
"{source} outputStyle {style_id:?} ({}) resolves to {}",
source_path.display(),
style_path.display()
),
),
_ => DoctorCheck::new(
"output_style",
CheckStatus::Fail,
format!(
"{source} outputStyle {style_id:?} ({}) is a known id but {} is missing or \
empty — run `tm install` to redeploy styles",
source_path.display(),
style_path.display()
),
),
}
}
fn resolve_effective_style_id(project_dir: Option<&Path>, home: &Path) -> Option<String> {
if let Some(dir) = project_dir
&& let Ok(StyleKey::Present(id)) =
read_style_key(&dir.join(".claude").join("settings.json"))
{
return Some(id);
}
match read_style_key(&home.join(".claude").join("settings.json")) {
Ok(StyleKey::Present(id)) => Some(id),
_ => None,
}
}
pub(crate) fn check_output_style_staleness(project_dir: Option<&Path>, home: &Path) -> DoctorCheck {
let styles_dir = home.join(".claude").join("output-styles");
let known_names: HashSet<&str> = OUTPUT_STYLES.iter().map(|s| s.file_name).collect();
let mut drifted: Vec<&str> = Vec::new();
for style in OUTPUT_STYLES {
match std::fs::read(styles_dir.join(style.file_name)) {
Ok(bytes) if bytes != style.content.as_bytes() => drifted.push(style.file_name),
_ => {} }
}
let mut orphans: Vec<String> = Vec::new();
if let Ok(entries) = std::fs::read_dir(&styles_dir) {
let exempt_custom =
resolve_effective_style_id(project_dir, home).map(|id| format!("{id}.md"));
for entry in entries.flatten() {
let file_name = entry.file_name();
let Some(name) = file_name.to_str() else {
continue;
};
if !name.ends_with(".md") || known_names.contains(name) {
continue;
}
if exempt_custom.as_deref() == Some(name) {
continue;
}
orphans.push(name.to_string());
}
}
orphans.sort_unstable();
if drifted.is_empty() && orphans.is_empty() {
return DoctorCheck::new(
"output_style_staleness",
CheckStatus::Ok,
"deployed output styles match the installed binary's bundled assets",
);
}
let mut parts: Vec<String> = Vec::new();
if !drifted.is_empty() {
parts.push(format!(
"{} drifted from bundled content ({}) — run `tm install` to redeploy",
drifted.len(),
drifted.join(", ")
));
}
if !orphans.is_empty() {
parts.push(format!(
"{} unrecognized file(s) present ({}) — not removed automatically; confirm they \
are not referenced by outputStyle before deleting",
orphans.len(),
orphans.join(", ")
));
}
DoctorCheck::new(
"output_style_staleness",
CheckStatus::Warn,
parts.join("; "),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn write_settings(dir: &Path, json: &str) {
let claude_dir = dir.join(".claude");
std::fs::create_dir_all(&claude_dir).unwrap();
std::fs::write(claude_dir.join("settings.json"), json).unwrap();
}
fn deploy_default_style(home: &Path) {
let styles_dir = home.join(".claude").join("output-styles");
std::fs::create_dir_all(&styles_dir).unwrap();
let default = OUTPUT_STYLES[0];
std::fs::write(styles_dir.join(default.file_name), default.content).unwrap();
}
fn deploy_all_styles(home: &Path) {
let styles_dir = home.join(".claude").join("output-styles");
std::fs::create_dir_all(&styles_dir).unwrap();
for style in OUTPUT_STYLES {
std::fs::write(styles_dir.join(style.file_name), style.content).unwrap();
}
}
#[test]
fn output_style_ok_when_style_resolves() {
let home = tempfile::tempdir().unwrap();
deploy_default_style(home.path());
write_settings(home.path(), r#"{"outputStyle": "trusty-mpm"}"#);
let check = check_output_style(None, home.path());
assert_eq!(check.status, CheckStatus::Ok);
}
#[test]
fn output_style_fail_when_id_unknown() {
let home = tempfile::tempdir().unwrap();
write_settings(home.path(), r#"{"outputStyle": "claude_mpm"}"#);
let check = check_output_style(None, home.path());
assert_eq!(check.status, CheckStatus::Fail);
assert!(check.message.contains("claude_mpm"));
assert!(
check.message.contains("trusty-mpm"),
"valid id list must be present"
);
}
#[test]
fn output_style_warn_when_key_absent() {
let home = tempfile::tempdir().unwrap();
write_settings(home.path(), r#"{}"#);
let check = check_output_style(None, home.path());
assert_eq!(check.status, CheckStatus::Warn);
}
#[test]
fn output_style_warn_when_settings_missing() {
let home = tempfile::tempdir().unwrap();
let check = check_output_style(None, home.path());
assert_eq!(check.status, CheckStatus::Warn);
}
#[test]
fn output_style_fail_when_file_missing() {
let home = tempfile::tempdir().unwrap();
write_settings(home.path(), r#"{"outputStyle": "trusty-mpm"}"#);
let check = check_output_style(None, home.path());
assert_eq!(check.status, CheckStatus::Fail);
}
#[test]
fn output_style_fail_on_malformed_json() {
let home = tempfile::tempdir().unwrap();
write_settings(home.path(), "{not valid json");
let check = check_output_style(None, home.path());
assert_eq!(check.status, CheckStatus::Fail);
assert!(check.message.contains("not valid JSON"));
}
#[test]
fn output_style_prefers_project_over_global() {
let home = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
deploy_default_style(home.path());
write_settings(home.path(), r#"{"outputStyle": "claude_mpm"}"#);
write_settings(project.path(), r#"{"outputStyle": "trusty-mpm"}"#);
let check = check_output_style(Some(project.path()), home.path());
assert_eq!(check.status, CheckStatus::Ok);
}
#[test]
fn output_style_falls_back_to_global_when_project_silent() {
let home = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
write_settings(home.path(), r#"{"outputStyle": "claude_mpm"}"#);
write_settings(project.path(), r#"{"theme": "dark"}"#);
let check = check_output_style(Some(project.path()), home.path());
assert_eq!(check.status, CheckStatus::Fail);
assert!(check.message.contains("claude_mpm"));
assert!(
check.message.contains("global"),
"message must name the resolved scope: {}",
check.message
);
}
#[test]
fn output_style_falls_back_to_global_when_project_missing() {
let home = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
write_settings(home.path(), r#"{"outputStyle": "claude_mpm"}"#);
let check = check_output_style(Some(project.path()), home.path());
assert_eq!(check.status, CheckStatus::Fail);
assert!(check.message.contains("claude_mpm"));
}
#[test]
fn output_style_reports_project_scope_on_success() {
let home = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
deploy_default_style(home.path());
write_settings(project.path(), r#"{"outputStyle": "trusty-mpm"}"#);
let check = check_output_style(Some(project.path()), home.path());
assert_eq!(check.status, CheckStatus::Ok);
assert!(
check.message.contains("project"),
"message must name the resolved scope: {}",
check.message
);
}
#[test]
fn staleness_ok_when_in_sync() {
let home = tempfile::tempdir().unwrap();
deploy_all_styles(home.path());
let check = check_output_style_staleness(None, home.path());
assert_eq!(check.status, CheckStatus::Ok, "message: {}", check.message);
}
#[test]
fn staleness_warns_on_drift() {
let home = tempfile::tempdir().unwrap();
deploy_all_styles(home.path());
let styles_dir = home.path().join(".claude").join("output-styles");
let first = &OUTPUT_STYLES[0];
std::fs::write(
styles_dir.join(first.file_name),
"stale pre-#2328 identity text",
)
.unwrap();
let check = check_output_style_staleness(None, home.path());
assert_eq!(check.status, CheckStatus::Warn);
assert!(check.message.contains(first.file_name));
assert!(check.message.contains("tm install"));
}
#[test]
fn staleness_warns_on_orphan() {
let home = tempfile::tempdir().unwrap();
deploy_all_styles(home.path());
let styles_dir = home.path().join(".claude").join("output-styles");
std::fs::write(styles_dir.join("claude-mpm.md"), "pre-rebrand lineage").unwrap();
let check = check_output_style_staleness(None, home.path());
assert_eq!(check.status, CheckStatus::Warn);
assert!(check.message.contains("claude-mpm.md"));
assert!(
styles_dir.join("claude-mpm.md").exists(),
"orphan detection must never delete the foreign file"
);
}
#[test]
fn staleness_ok_when_dir_missing() {
let home = tempfile::tempdir().unwrap();
let check = check_output_style_staleness(None, home.path());
assert_eq!(check.status, CheckStatus::Ok);
}
#[test]
fn staleness_ok_when_file_never_deployed() {
let home = tempfile::tempdir().unwrap();
deploy_default_style(home.path()); let check = check_output_style_staleness(None, home.path());
assert_eq!(check.status, CheckStatus::Ok, "message: {}", check.message);
}
#[test]
fn staleness_orphan_exempts_configured_custom_id() {
let home = tempfile::tempdir().unwrap();
deploy_all_styles(home.path());
write_settings(home.path(), r#"{"outputStyle": "my-custom-style"}"#);
let styles_dir = home.path().join(".claude").join("output-styles");
std::fs::write(
styles_dir.join("my-custom-style.md"),
"operator's own style",
)
.unwrap();
let check = check_output_style_staleness(None, home.path());
assert_eq!(check.status, CheckStatus::Ok, "message: {}", check.message);
}
}