Skip to main content

mars_agents/cli/
doctor.rs

1//! `mars doctor` — validate state consistency.
2
3use crate::error::MarsError;
4use crate::hash;
5
6use super::output;
7
8/// Arguments for `mars doctor`.
9#[derive(Debug, clap::Args)]
10pub struct DoctorArgs {}
11
12/// Run `mars doctor`.
13pub fn run(_args: &DoctorArgs, ctx: &super::MarsContext, json: bool) -> Result<i32, MarsError> {
14    let mut errors = Vec::new();
15    let mut warnings = Vec::new();
16
17    // Check config is valid
18    let config = match crate::config::load(&ctx.project_root) {
19        Ok(config) => Some(config),
20        Err(e) => {
21            errors.push(format!("config error: {e}"));
22            None
23        }
24    };
25
26    // Check lock file
27    let lock = match crate::lock::load(&ctx.project_root) {
28        Ok(l) => l,
29        Err(e) => {
30            errors.push(format!("lock file error: {e}"));
31            output::print_doctor(&errors, &warnings, json);
32            return Ok(2);
33        }
34    };
35
36    // Check each locked item against .mars/ canonical store
37    let mars_dir = ctx.project_root.join(".mars");
38    for (dest_path_str, item) in &lock.items {
39        let disk_path = dest_path_str.resolve(&mars_dir);
40
41        // Check file exists
42        if !disk_path.exists() {
43            errors.push(format!(
44                "{dest_path_str} missing from disk. Run `mars sync` to reinstall or `mars repair` to rebuild"
45            ));
46            continue;
47        }
48
49        // Check for conflict markers
50        if item.kind == crate::lock::ItemKind::Agent
51            && let Ok(content) = std::fs::read_to_string(&disk_path)
52            && content.contains("<<<<<<<")
53            && content.contains(">>>>>>>")
54        {
55            errors.push(format!("{dest_path_str} has unresolved conflict markers"));
56        }
57
58        // Check checksum matches
59        match hash::compute_hash(&disk_path, item.kind) {
60            Ok(disk_hash) => {
61                if disk_hash != item.installed_checksum {
62                    // Not necessarily an issue — could be a local modification
63                    // But we report it as informational
64                }
65            }
66            Err(e) => {
67                errors.push(format!("can't hash {dest_path_str}: {e}"));
68            }
69        }
70    }
71
72    // Check agent→skill references
73    if let Some(config) = &config {
74        let local = crate::config::load_local(&ctx.project_root).unwrap_or_default();
75        if let Ok((effective, _diagnostics)) =
76            crate::config::merge_with_root(config.clone(), local, &ctx.project_root)
77        {
78            // Check that all sources in config have corresponding lock entries
79            for source_name in effective.dependencies.keys() {
80                if !lock.dependencies.contains_key(source_name) {
81                    errors.push(format!(
82                        "dependency `{source_name}` is in config but not in lock — run `mars sync`"
83                    ));
84                }
85            }
86        }
87    }
88
89    // Check skill dependencies — every agent's declared skills must exist on disk.
90    // Uses discover_installed() to scan the actual filesystem, catching both
91    // mars-managed and user-created local agents/skills.
92    {
93        use std::collections::HashSet;
94
95        let installed = crate::discover::discover_installed(&mars_dir)?;
96
97        // Warn on legacy symlinks found in managed directories.
98        for item in installed.agents.iter().chain(installed.skills.iter()) {
99            if item
100                .path
101                .symlink_metadata()
102                .map(|m| m.file_type().is_symlink())
103                .unwrap_or(false)
104            {
105                let kind = if item.id.kind == crate::lock::ItemKind::Agent {
106                    "agent"
107                } else {
108                    "skill"
109                };
110                warnings.push(format!(
111                    "legacy symlinked {kind} `{}` detected in managed dir — run `mars sync` to normalize to copied content",
112                    item.id.name,
113                ));
114            }
115        }
116
117        let available_skills: HashSet<String> = installed
118            .skills
119            .iter()
120            .map(|s| s.id.name.to_string())
121            .collect();
122
123        let agents_for_check: Vec<(String, std::path::PathBuf)> = installed
124            .agents
125            .iter()
126            .map(|a| (a.id.name.to_string(), a.path.clone()))
127            .collect();
128
129        if let Ok(warnings) = crate::validate::check_deps(&agents_for_check, &available_skills) {
130            for w in &warnings {
131                match w {
132                    crate::validate::ValidationWarning::MissingSkill {
133                        agent,
134                        skill_name,
135                        suggestion,
136                    } => {
137                        let msg = match suggestion {
138                            Some(s) => format!(
139                                "agent `{}` references missing skill `{skill_name}` (did you mean `{s}`?)",
140                                agent.name
141                            ),
142                            None => format!(
143                                "agent `{}` references missing skill `{skill_name}` — \
144                                 add a source that provides it, or create it locally in skills/{skill_name}/",
145                                agent.name
146                            ),
147                        };
148                        errors.push(msg);
149                    }
150                }
151            }
152        }
153    }
154
155    // Check .mars/ gitignore (D29) as warning only.
156    check_mars_gitignore(&ctx.project_root, &mut warnings);
157
158    // Check managed targets (.agents/, .claude/, etc.) against lock checksums.
159    if let Some(config) = &config {
160        let target_divergence_count = check_target_divergence(
161            &ctx.project_root,
162            &lock,
163            &config.settings.managed_targets(),
164            &mut warnings,
165        );
166        if target_divergence_count > 0 {
167            warnings.push(
168                "target divergence detected; run `mars sync --force` to reset modified files or `mars repair` to restore missing files".to_string(),
169            );
170        }
171    }
172
173    output::print_doctor(&errors, &warnings, json);
174
175    if errors.is_empty() { Ok(0) } else { Ok(2) }
176}
177
178/// Check if .mars/ is properly gitignored (D29).
179///
180/// Mars does NOT auto-edit .gitignore — it only warns via `mars doctor`.
181fn check_mars_gitignore(project_root: &std::path::Path, warnings: &mut Vec<String>) {
182    let mars_dir = project_root.join(".mars");
183    if !mars_dir.exists() {
184        return;
185    }
186
187    let gitignore_path = project_root.join(".gitignore");
188    let is_ignored = match std::fs::read_to_string(&gitignore_path) {
189        Ok(content) => content.lines().any(|line| {
190            let trimmed = line.trim();
191            trimmed == ".mars" || trimmed == ".mars/" || trimmed == "/.mars" || trimmed == "/.mars/"
192        }),
193        Err(_) => false,
194    };
195
196    if !is_ignored {
197        warnings.push(
198            ".mars/ is not in .gitignore — add `.mars/` to your .gitignore to avoid committing cached data"
199                .to_string(),
200        );
201    }
202}
203
204/// Check managed target directories against lockfile-installed checksums.
205///
206/// Returns the number of divergent or missing target items found.
207fn check_target_divergence(
208    project_root: &std::path::Path,
209    lock: &crate::lock::LockFile,
210    targets: &[String],
211    warnings: &mut Vec<String>,
212) -> usize {
213    let mut divergence_count = 0;
214
215    for target_name in targets {
216        for (dest_path, item) in &lock.items {
217            let relative_path = std::path::Path::new(target_name).join(dest_path.as_str());
218            let target_path = project_root.join(&relative_path);
219
220            if !target_path.exists() && target_path.symlink_metadata().is_err() {
221                warnings.push(format!(
222                    "missing in target: {}/{}",
223                    target_name,
224                    dest_path.as_str()
225                ));
226                divergence_count += 1;
227                continue;
228            }
229
230            match hash::compute_hash(&target_path, item.kind) {
231                Ok(target_hash) => {
232                    if target_hash != item.installed_checksum {
233                        warnings.push(format!(
234                            "divergent in target: {}/{} (local modifications)",
235                            target_name,
236                            dest_path.as_str()
237                        ));
238                        divergence_count += 1;
239                    }
240                }
241                Err(e) => {
242                    warnings.push(format!(
243                        "divergent in target: {}/{} (local modifications; failed to hash: {e})",
244                        target_name,
245                        dest_path.as_str()
246                    ));
247                    divergence_count += 1;
248                }
249            }
250        }
251    }
252
253    divergence_count
254}
255
256#[cfg(test)]
257mod tests {
258    use std::fs;
259
260    use tempfile::TempDir;
261
262    use super::check_target_divergence;
263
264    fn make_locked_agent(dest_path: &str, expected_content: &str) -> crate::lock::LockedItem {
265        let expected_hash = crate::hash::hash_bytes(expected_content.as_bytes());
266        crate::lock::LockedItem {
267            source: "test-source".into(),
268            kind: crate::lock::ItemKind::Agent,
269            version: None,
270            source_checksum: expected_hash.clone().into(),
271            installed_checksum: expected_hash.into(),
272            dest_path: dest_path.into(),
273        }
274    }
275
276    #[test]
277    fn check_target_divergence_warns_for_missing_and_modified_items() {
278        let temp = TempDir::new().expect("create temp dir");
279        let root = temp.path();
280
281        let mut lock = crate::lock::LockFile::empty();
282        lock.items.insert(
283            "agents/missing.md".into(),
284            make_locked_agent("agents/missing.md", "expected missing"),
285        );
286        lock.items.insert(
287            "agents/modified.md".into(),
288            make_locked_agent("agents/modified.md", "expected content"),
289        );
290
291        fs::create_dir_all(root.join(".agents/agents")).expect("create target dir");
292        fs::write(root.join(".agents/agents/modified.md"), "local edits")
293            .expect("write modified file");
294
295        let mut warnings = Vec::new();
296        let divergences =
297            check_target_divergence(root, &lock, &[".agents".to_string()], &mut warnings);
298
299        assert_eq!(divergences, 2);
300        assert!(
301            warnings
302                .iter()
303                .any(|w| w == "missing in target: .agents/agents/missing.md")
304        );
305        assert!(
306            warnings
307                .iter()
308                .any(|w| w
309                    == "divergent in target: .agents/agents/modified.md (local modifications)")
310        );
311    }
312
313    #[test]
314    fn check_target_divergence_checks_every_managed_target() {
315        let temp = TempDir::new().expect("create temp dir");
316        let root = temp.path();
317
318        let mut lock = crate::lock::LockFile::empty();
319        lock.items.insert(
320            "agents/test.md".into(),
321            make_locked_agent("agents/test.md", "expected content"),
322        );
323
324        fs::create_dir_all(root.join(".agents/agents")).expect("create .agents tree");
325        fs::write(root.join(".agents/agents/test.md"), "expected content")
326            .expect("write matching file");
327
328        let mut warnings = Vec::new();
329        let divergences = check_target_divergence(
330            root,
331            &lock,
332            &[".agents".to_string(), ".claude".to_string()],
333            &mut warnings,
334        );
335
336        assert_eq!(divergences, 1);
337        assert!(
338            warnings
339                .iter()
340                .any(|w| w == "missing in target: .claude/agents/test.md")
341        );
342    }
343}