use crate::error::MarsError;
use crate::hash;
use super::output;
#[derive(Debug, clap::Args)]
pub struct DoctorArgs {}
pub fn run(_args: &DoctorArgs, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
let mut errors = Vec::new();
let mut warnings = Vec::new();
let config = match crate::config::load(&ctx.project_root) {
Ok(config) => Some(config),
Err(e) => {
errors.push(format!("config error: {e}"));
None
}
};
let lock = match crate::lock::load(&ctx.project_root) {
Ok(l) => l,
Err(e) => {
errors.push(format!("lock file error: {e}"));
output::print_doctor(&errors, &warnings, json);
return Ok(2);
}
};
let mars_dir = ctx.project_root.join(".mars");
for (dest_path_str, item) in &lock.items {
let disk_path = mars_dir.join(dest_path_str);
if !disk_path.exists() {
errors.push(format!(
"{dest_path_str} missing from disk. Run `mars sync` to reinstall or `mars repair` to rebuild"
));
continue;
}
if item.kind == crate::lock::ItemKind::Agent
&& let Ok(content) = std::fs::read_to_string(&disk_path)
&& content.contains("<<<<<<<")
&& content.contains(">>>>>>>")
{
errors.push(format!("{dest_path_str} has unresolved conflict markers"));
}
match hash::compute_hash(&disk_path, item.kind) {
Ok(disk_hash) => {
if disk_hash != item.installed_checksum {
}
}
Err(e) => {
errors.push(format!("can't hash {dest_path_str}: {e}"));
}
}
}
if let Some(config) = &config {
let local = crate::config::load_local(&ctx.project_root).unwrap_or_default();
if let Ok((effective, _diagnostics)) =
crate::config::merge_with_root(config.clone(), local, &ctx.project_root)
{
for source_name in effective.dependencies.keys() {
if !lock.dependencies.contains_key(source_name) {
errors.push(format!(
"dependency `{source_name}` is in config but not in lock — run `mars sync`"
));
}
}
}
}
{
use std::collections::HashSet;
let installed = crate::discover::discover_installed(&mars_dir)?;
for item in installed.agents.iter().chain(installed.skills.iter()) {
if item
.path
.symlink_metadata()
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
let kind = if item.id.kind == crate::lock::ItemKind::Agent {
"agent"
} else {
"skill"
};
warnings.push(format!(
"legacy symlinked {kind} `{}` detected in managed dir — run `mars sync` to normalize to copied content",
item.id.name,
));
}
}
let available_skills: HashSet<String> = installed
.skills
.iter()
.map(|s| s.id.name.to_string())
.collect();
let agents_for_check: Vec<(String, std::path::PathBuf)> = installed
.agents
.iter()
.map(|a| (a.id.name.to_string(), a.path.clone()))
.collect();
if let Ok(warnings) = crate::validate::check_deps(&agents_for_check, &available_skills) {
for w in &warnings {
match w {
crate::validate::ValidationWarning::MissingSkill {
agent,
skill_name,
suggestion,
} => {
let msg = match suggestion {
Some(s) => format!(
"agent `{}` references missing skill `{skill_name}` (did you mean `{s}`?)",
agent.name
),
None => format!(
"agent `{}` references missing skill `{skill_name}` — \
add a source that provides it, or create it locally in skills/{skill_name}/",
agent.name
),
};
errors.push(msg);
}
}
}
}
}
check_mars_gitignore(&ctx.project_root, &mut warnings);
if let Some(config) = &config {
let target_divergence_count = check_target_divergence(
&ctx.project_root,
&lock,
&config.settings.managed_targets(),
&mut warnings,
);
if target_divergence_count > 0 {
warnings.push(
"target divergence detected; run `mars sync --force` to reset modified files or `mars repair` to restore missing files".to_string(),
);
}
}
output::print_doctor(&errors, &warnings, json);
if errors.is_empty() { Ok(0) } else { Ok(2) }
}
fn check_mars_gitignore(project_root: &std::path::Path, warnings: &mut Vec<String>) {
let mars_dir = project_root.join(".mars");
if !mars_dir.exists() {
return;
}
let gitignore_path = project_root.join(".gitignore");
let is_ignored = match std::fs::read_to_string(&gitignore_path) {
Ok(content) => content.lines().any(|line| {
let trimmed = line.trim();
trimmed == ".mars" || trimmed == ".mars/" || trimmed == "/.mars" || trimmed == "/.mars/"
}),
Err(_) => false,
};
if !is_ignored {
warnings.push(
".mars/ is not in .gitignore — add `.mars/` to your .gitignore to avoid committing cached data"
.to_string(),
);
}
}
fn check_target_divergence(
project_root: &std::path::Path,
lock: &crate::lock::LockFile,
targets: &[String],
warnings: &mut Vec<String>,
) -> usize {
let mut divergence_count = 0;
for target_name in targets {
for (dest_path, item) in &lock.items {
let relative_path = std::path::Path::new(target_name).join(dest_path.as_path());
let target_path = project_root.join(&relative_path);
if !target_path.exists() && target_path.symlink_metadata().is_err() {
warnings.push(format!("missing in target: {}", relative_path.display()));
divergence_count += 1;
continue;
}
match hash::compute_hash(&target_path, item.kind) {
Ok(target_hash) => {
if target_hash != item.installed_checksum {
warnings.push(format!(
"divergent in target: {} (local modifications)",
relative_path.display()
));
divergence_count += 1;
}
}
Err(e) => {
warnings.push(format!(
"divergent in target: {} (local modifications; failed to hash: {e})",
relative_path.display()
));
divergence_count += 1;
}
}
}
}
divergence_count
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::TempDir;
use super::check_target_divergence;
fn make_locked_agent(dest_path: &str, expected_content: &str) -> crate::lock::LockedItem {
let expected_hash = crate::hash::hash_bytes(expected_content.as_bytes());
crate::lock::LockedItem {
source: "test-source".into(),
kind: crate::lock::ItemKind::Agent,
version: None,
source_checksum: expected_hash.clone().into(),
installed_checksum: expected_hash.into(),
dest_path: dest_path.into(),
}
}
#[test]
fn check_target_divergence_warns_for_missing_and_modified_items() {
let temp = TempDir::new().expect("create temp dir");
let root = temp.path();
let mut lock = crate::lock::LockFile::empty();
lock.items.insert(
"agents/missing.md".into(),
make_locked_agent("agents/missing.md", "expected missing"),
);
lock.items.insert(
"agents/modified.md".into(),
make_locked_agent("agents/modified.md", "expected content"),
);
fs::create_dir_all(root.join(".agents/agents")).expect("create target dir");
fs::write(root.join(".agents/agents/modified.md"), "local edits")
.expect("write modified file");
let mut warnings = Vec::new();
let divergences =
check_target_divergence(root, &lock, &[".agents".to_string()], &mut warnings);
assert_eq!(divergences, 2);
assert!(
warnings
.iter()
.any(|w| w == "missing in target: .agents/agents/missing.md")
);
assert!(
warnings
.iter()
.any(|w| w
== "divergent in target: .agents/agents/modified.md (local modifications)")
);
}
#[test]
fn check_target_divergence_checks_every_managed_target() {
let temp = TempDir::new().expect("create temp dir");
let root = temp.path();
let mut lock = crate::lock::LockFile::empty();
lock.items.insert(
"agents/test.md".into(),
make_locked_agent("agents/test.md", "expected content"),
);
fs::create_dir_all(root.join(".agents/agents")).expect("create .agents tree");
fs::write(root.join(".agents/agents/test.md"), "expected content")
.expect("write matching file");
let mut warnings = Vec::new();
let divergences = check_target_divergence(
root,
&lock,
&[".agents".to_string(), ".claude".to_string()],
&mut warnings,
);
assert_eq!(divergences, 1);
assert!(
warnings
.iter()
.any(|w| w == "missing in target: .claude/agents/test.md")
);
}
}