algocline_app/service/pkg/
doctor.rs1use std::path::Path;
27
28use tracing::warn;
29
30use super::super::manifest::{load_manifest, Manifest, ManifestEntry};
31use super::super::project::resolve_project_root;
32use super::super::resolve::packages_dir;
33use super::super::source::PackageSource;
34use super::super::AppService;
35use super::repair::{
36 collect_path_missing, collect_unattached_dangling_symlinks, symlink_dangling_suggestion,
37 ProjectPathSource,
38};
39
40enum DoctorOutcome {
42 Healthy,
44 SymlinkDangling { reason: String, suggestion: String },
46 InstalledMissing { reason: String, suggestion: String },
48}
49
50#[derive(Default)]
52struct DoctorBuckets {
53 healthy: Vec<serde_json::Value>,
54 installed_missing: Vec<serde_json::Value>,
55 symlink_dangling: Vec<serde_json::Value>,
56 path_missing: Vec<serde_json::Value>,
57}
58
59impl DoctorBuckets {
60 fn any_matched(&self) -> bool {
61 !self.healthy.is_empty()
62 || !self.installed_missing.is_empty()
63 || !self.symlink_dangling.is_empty()
64 || !self.path_missing.is_empty()
65 }
66
67 fn into_json(self) -> String {
68 serde_json::json!({
72 "healthy": self.healthy,
73 "installed_missing": self.installed_missing,
74 "symlink_dangling": self.symlink_dangling,
75 "path_missing": self.path_missing,
76 })
77 .to_string()
78 }
79}
80
81fn installed_missing_suggestion(name: &str, entry_source: &PackageSource) -> String {
92 match entry_source {
93 PackageSource::Bundled { .. } => {
94 "alc_init (reinstalls bundled packages from the algocline binary)".to_string()
95 }
96 PackageSource::Path { path } => {
97 format!("alc_pkg_install({path:?}) to reinstall {name:?} from local path")
98 }
99 PackageSource::Git { url, .. } => {
100 format!("alc_pkg_install({url:?}) to reinstall {name:?} from Git")
101 }
102 PackageSource::Installed => {
103 format!(
104 "alc_pkg_install <path-or-url> to re-record source for {name:?} \
105 (legacy 'installed' marker carries no path)"
106 )
107 }
108 PackageSource::Unknown => {
109 format!(
110 "alc_hub_reindex then alc_pkg_install <path-or-url> for {name:?} \
111 (source unknown — legacy entry)"
112 )
113 }
114 }
115}
116
117fn push_doctor_outcome(name: &str, outcome: DoctorOutcome, buckets: &mut DoctorBuckets) {
119 match outcome {
120 DoctorOutcome::Healthy => buckets.healthy.push(serde_json::json!({
121 "name": name,
122 })),
123 DoctorOutcome::SymlinkDangling { reason, suggestion } => {
124 buckets.symlink_dangling.push(serde_json::json!({
125 "name": name,
126 "kind": "symlink_dangling",
127 "reason": reason,
128 "suggestion": suggestion,
129 }))
130 }
131 DoctorOutcome::InstalledMissing { reason, suggestion } => {
132 buckets.installed_missing.push(serde_json::json!({
133 "name": name,
134 "kind": "installed_missing",
135 "reason": reason,
136 "suggestion": suggestion,
137 }))
138 }
139 }
140}
141
142fn classify_installed(name: &str, entry: &ManifestEntry, pkg_dir: &Path) -> DoctorOutcome {
146 let dest = pkg_dir.join(name);
147
148 let is_symlink = dest
149 .symlink_metadata()
150 .map(|m| m.file_type().is_symlink())
151 .unwrap_or(false);
152 if is_symlink {
153 let target_alive = match dest.try_exists() {
155 Ok(v) => v,
156 Err(e) => {
157 warn!(error = %e, path = %dest.display(), "try_exists failed; treating symlink target as dead");
158 false
159 }
160 };
161 if target_alive {
162 return DoctorOutcome::Healthy;
163 }
164 let link_target = match dest.read_link() {
165 Ok(t) => t.display().to_string(),
166 Err(e) => {
167 warn!(error = %e, path = %dest.display(), "read_link failed; using placeholder for dangling target");
168 "<unknown>".to_string()
169 }
170 };
171 return DoctorOutcome::SymlinkDangling {
172 reason: format!("symlink target missing: {link_target}"),
173 suggestion: symlink_dangling_suggestion(name),
174 };
175 }
176
177 if dest.exists() {
178 return DoctorOutcome::Healthy;
179 }
180
181 DoctorOutcome::InstalledMissing {
182 reason: format!("installed directory missing: {}", dest.display()),
183 suggestion: installed_missing_suggestion(name, &entry.source),
184 }
185}
186
187fn run_manifest_pass(
191 manifest: &Manifest,
192 target_filter: Option<&str>,
193 pkg_dir: &Path,
194 buckets: &mut DoctorBuckets,
195) {
196 if let Some(target) = target_filter {
197 if let Some(entry) = manifest.packages.get(target) {
198 let outcome = classify_installed(target, entry, pkg_dir);
199 push_doctor_outcome(target, outcome, buckets);
200 }
201 return;
202 }
203 for (pkg_name, entry) in &manifest.packages {
204 let outcome = classify_installed(pkg_name, entry, pkg_dir);
205 push_doctor_outcome(pkg_name, outcome, buckets);
206 }
207}
208
209fn run_unattached_symlink_pass(
213 pkg_dir: &Path,
214 target_filter: Option<&str>,
215 manifest: &Manifest,
216 buckets: &mut DoctorBuckets,
217) {
218 let mut scratch: Vec<serde_json::Value> = Vec::new();
219 collect_unattached_dangling_symlinks(pkg_dir, target_filter, &manifest.packages, &mut scratch);
220 buckets.symlink_dangling.extend(scratch);
221}
222
223fn run_path_missing_pass(
227 resolved_root: Option<&Path>,
228 target_filter: Option<&str>,
229 buckets: &mut DoctorBuckets,
230) {
231 let Some(root) = resolved_root else {
232 return;
233 };
234 let mut scratch: Vec<serde_json::Value> = Vec::new();
235 collect_path_missing(
236 root,
237 target_filter,
238 "project",
239 &mut scratch,
240 ProjectPathSource::Toml,
241 );
242 collect_path_missing(
243 root,
244 target_filter,
245 "variant",
246 &mut scratch,
247 ProjectPathSource::Local,
248 );
249 buckets.path_missing.extend(scratch);
250}
251
252impl AppService {
253 pub async fn pkg_doctor(
269 &self,
270 name: Option<String>,
271 project_root: Option<String>,
272 ) -> Result<String, String> {
273 let app_dir = self.log_config.app_dir();
274 let manifest = load_manifest(&app_dir)?;
275 let pkg_dir = packages_dir(&app_dir);
276 let resolved_root = resolve_project_root(project_root.as_deref());
277 let target_filter = name.as_deref();
278
279 let mut buckets = DoctorBuckets::default();
280 run_manifest_pass(&manifest, target_filter, &pkg_dir, &mut buckets);
281 run_unattached_symlink_pass(&pkg_dir, target_filter, &manifest, &mut buckets);
282 run_path_missing_pass(resolved_root.as_deref(), target_filter, &mut buckets);
283
284 if let Some(target) = target_filter {
285 if !buckets.any_matched() {
286 return Err(format!(
287 "Package '{target}' not found in installed.json, ~/.algocline/packages/, alc.toml, or alc.local.toml"
288 ));
289 }
290 }
291
292 Ok(buckets.into_json())
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use std::path::PathBuf;
300
301 fn mk_entry(source: &str) -> ManifestEntry {
305 ManifestEntry {
306 version: None,
307 source: PackageSource::Path {
308 path: source.to_string(),
309 },
310 installed_at: "2026-01-01T00:00:00Z".to_string(),
311 updated_at: "2026-01-01T00:00:00Z".to_string(),
312 }
313 }
314
315 #[test]
316 fn classify_installed_healthy_dir() {
317 let tmp = tempfile::tempdir().unwrap();
318 let pkg_dir = tmp.path();
319 std::fs::create_dir(pkg_dir.join("p")).unwrap();
320
321 let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir);
322 assert!(matches!(outcome, DoctorOutcome::Healthy));
323 }
324
325 #[test]
326 fn classify_installed_missing_dir() {
327 let tmp = tempfile::tempdir().unwrap();
328 let pkg_dir = tmp.path();
329
330 let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir);
331 match outcome {
332 DoctorOutcome::InstalledMissing { reason, suggestion } => {
333 assert!(
334 reason.contains("installed directory missing"),
335 "reason = {reason}"
336 );
337 assert!(
338 suggestion.contains("alc_pkg_install"),
339 "suggestion = {suggestion}"
340 );
341 assert!(
342 suggestion.contains("/src/p"),
343 "suggestion carries source: {suggestion}"
344 );
345 }
346 _ => panic!("expected InstalledMissing"),
347 }
348 }
349
350 #[test]
351 #[cfg(unix)]
352 fn classify_installed_symlink_dangling() {
353 use std::os::unix::fs::symlink;
354
355 let tmp = tempfile::tempdir().unwrap();
356 let pkg_dir = tmp.path();
357 let dangling_target = PathBuf::from("/nonexistent/path/for/doctor_test");
358 symlink(&dangling_target, pkg_dir.join("p")).unwrap();
359
360 let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir);
361 match outcome {
362 DoctorOutcome::SymlinkDangling { reason, suggestion } => {
363 assert!(reason.contains("symlink target missing"), "{reason}");
364 assert!(suggestion.contains("alc_pkg_unlink"), "{suggestion}");
365 }
366 _ => panic!("expected SymlinkDangling"),
367 }
368 }
369
370 #[test]
371 #[cfg(unix)]
372 fn classify_installed_symlink_alive() {
373 use std::os::unix::fs::symlink;
374
375 let tmp = tempfile::tempdir().unwrap();
376 let real_target = tmp.path().join("real_target_dir");
377 std::fs::create_dir(&real_target).unwrap();
378
379 let pkg_dir = tmp.path().join("pkgs");
380 std::fs::create_dir(&pkg_dir).unwrap();
381 symlink(&real_target, pkg_dir.join("q")).unwrap();
382
383 let outcome = classify_installed("q", &mk_entry("/src/q"), &pkg_dir);
384 assert!(matches!(outcome, DoctorOutcome::Healthy));
385 }
386
387 #[test]
388 fn buckets_into_json_emits_all_four_keys() {
389 let mut b = DoctorBuckets::default();
395 b.healthy.push(serde_json::json!({"name": "h"}));
396 b.installed_missing
397 .push(serde_json::json!({"name": "i", "kind": "installed_missing"}));
398 b.symlink_dangling
399 .push(serde_json::json!({"name": "s", "kind": "symlink_dangling"}));
400 b.path_missing
401 .push(serde_json::json!({"name": "p", "kind": "path_missing"}));
402
403 let out = b.into_json();
404 let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
405 let obj = parsed.as_object().expect("JSON object");
406 assert!(obj.contains_key("healthy"));
407 assert!(obj.contains_key("installed_missing"));
408 assert!(obj.contains_key("symlink_dangling"));
409 assert!(obj.contains_key("path_missing"));
410 assert_eq!(obj.len(), 4, "exactly four top-level buckets: {out}");
411
412 assert_eq!(obj["healthy"][0]["name"], "h");
413 assert_eq!(obj["installed_missing"][0]["name"], "i");
414 assert_eq!(obj["symlink_dangling"][0]["name"], "s");
415 assert_eq!(obj["path_missing"][0]["name"], "p");
416 }
417
418 #[test]
419 fn any_matched_tracks_all_buckets() {
420 let mut b = DoctorBuckets::default();
421 assert!(!b.any_matched());
422 b.healthy.push(serde_json::json!({"name": "h"}));
423 assert!(b.any_matched());
424
425 let mut b = DoctorBuckets::default();
426 b.installed_missing.push(serde_json::json!({}));
427 assert!(b.any_matched());
428
429 let mut b = DoctorBuckets::default();
430 b.symlink_dangling.push(serde_json::json!({}));
431 assert!(b.any_matched());
432
433 let mut b = DoctorBuckets::default();
434 b.path_missing.push(serde_json::json!({}));
435 assert!(b.any_matched());
436 }
437
438 #[test]
439 fn installed_missing_suggestion_shape() {
440 let git = PackageSource::Git {
441 url: "github.com/foo/bar".to_string(),
442 rev: None,
443 };
444 let s = installed_missing_suggestion("ucb", &git);
445 assert!(s.contains("alc_pkg_install"), "{s}");
446 assert!(s.contains("\"ucb\""), "{s}");
447 assert!(s.contains("github.com/foo/bar"), "{s}");
448 }
449
450 #[test]
455 fn installed_missing_suggestion_routes_bundled_to_alc_init() {
456 let bundled = PackageSource::Bundled { collection: None };
457 let s = installed_missing_suggestion("ucb", &bundled);
458 assert!(s.contains("alc_init"), "bundled must suggest alc_init: {s}");
459 assert!(
460 !s.contains("alc_pkg_install"),
461 "bundled must NOT suggest alc_pkg_install: {s}"
462 );
463 }
464
465 #[test]
472 fn installed_missing_suggestion_routes_absolute_path_to_pkg_install() {
473 let local = PackageSource::Path {
474 path: "/abs/path/to/src".to_string(),
475 };
476 let s = installed_missing_suggestion("local_pkg", &local);
477 assert!(s.contains("alc_pkg_install"), "{s}");
478 assert!(s.contains("/abs/path/to/src"), "{s}");
479 }
480
481 #[test]
485 fn installed_missing_suggestion_routes_unknown_to_reindex() {
486 let s = installed_missing_suggestion("legacy_pkg", &PackageSource::Unknown);
487 assert!(
488 s.contains("alc_hub_reindex"),
489 "Unknown must suggest alc_hub_reindex: {s}"
490 );
491 }
492}