mars_agents/cli/
doctor.rs1use crate::error::MarsError;
4use crate::hash;
5
6use super::output;
7
8#[derive(Debug, clap::Args)]
10pub struct DoctorArgs {}
11
12pub 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 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 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 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 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 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 match hash::compute_hash(&disk_path, item.kind) {
60 Ok(disk_hash) => {
61 if disk_hash != item.installed_checksum {
62 }
65 }
66 Err(e) => {
67 errors.push(format!("can't hash {dest_path_str}: {e}"));
68 }
69 }
70 }
71
72 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 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 {
93 use std::collections::HashSet;
94
95 let installed = crate::discover::discover_installed(&mars_dir)?;
96
97 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(&ctx.project_root, &mut warnings);
157
158 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
178fn 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
204fn 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}