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()
),
),
}
}
#[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();
}
#[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
);
}
}