1use std::path::Path;
41
42use tracing::warn;
43
44use super::super::manifest::{load_manifest, Manifest, ManifestEntry};
45use super::super::resolve::packages_dir;
46use super::super::source::PackageSource;
47use super::super::AppService;
48use super::repair::{
49 collect_path_missing, collect_unattached_dangling_symlinks, symlink_dangling_suggestion,
50 ProjectPathSource,
51};
52
53#[derive(Debug)]
55enum DoctorOutcome {
56 Healthy,
58 SymlinkDangling { reason: String, suggestion: String },
60 InstalledMissing { reason: String, suggestion: String },
62 IncompletePkg {
65 missing_subs: Vec<String>,
66 suggestion: String,
67 },
68}
69
70#[derive(Default)]
72struct DoctorBuckets {
73 healthy: Vec<serde_json::Value>,
74 installed_missing: Vec<serde_json::Value>,
75 symlink_dangling: Vec<serde_json::Value>,
76 path_missing: Vec<serde_json::Value>,
77 incomplete_pkg: Vec<serde_json::Value>,
78}
79
80impl DoctorBuckets {
81 fn any_matched(&self) -> bool {
82 !self.healthy.is_empty()
83 || !self.installed_missing.is_empty()
84 || !self.symlink_dangling.is_empty()
85 || !self.path_missing.is_empty()
86 || !self.incomplete_pkg.is_empty()
87 }
88
89 fn into_json(self) -> String {
90 serde_json::json!({
94 "healthy": self.healthy,
95 "incomplete_pkg": self.incomplete_pkg,
96 "installed_missing": self.installed_missing,
97 "symlink_dangling": self.symlink_dangling,
98 "path_missing": self.path_missing,
99 })
100 .to_string()
101 }
102}
103
104fn extract_required_subs(lua_src: &str, pkg_name: &str) -> Vec<String> {
118 let mut subs = Vec::new();
119 let prefix = format!("{pkg_name}.");
120 let mut remaining = lua_src;
121
122 while let Some(pos) = remaining.find("require") {
123 remaining = &remaining[pos + "require".len()..];
124
125 let trimmed = remaining.trim_start_matches([' ', '\t']);
127
128 if !trimmed.starts_with('(') {
130 continue;
131 }
132 let after_paren = &trimmed[1..];
133 let after_paren = after_paren.trim_start_matches([' ', '\t']);
134
135 let quote = match after_paren.chars().next() {
137 Some(q @ '"') | Some(q @ '\'') => q,
138 _ => continue,
139 };
140 let content = &after_paren[1..];
141 let end = match content.find(quote) {
142 Some(i) => i,
143 None => continue,
144 };
145 let module = &content[..end];
146
147 if let Some(sub) = module.strip_prefix(&prefix) {
148 if !sub.is_empty() && !sub.contains('.') {
149 subs.push(sub.to_string());
151 }
152 }
153 }
154
155 subs.sort();
156 subs.dedup();
157 subs
158}
159
160fn incomplete_pkg_suggestion(name: &str, is_symlink: bool) -> String {
163 if is_symlink {
164 format!("Re-run alc_pkg_link <path> to re-link {name:?} with the complete source directory")
165 } else {
166 format!(
167 "Run alc_pkg_install --force {name:?} to reinstall {name:?} with all submodule files"
168 )
169 }
170}
171
172fn installed_missing_suggestion(name: &str, entry_source: &PackageSource) -> String {
183 match entry_source {
184 PackageSource::Bundled { .. } => {
185 "alc_init (reinstalls bundled packages from the algocline binary)".to_string()
186 }
187 PackageSource::Path { path } => {
188 format!("alc_pkg_install({path:?}) to reinstall {name:?} from local path")
189 }
190 PackageSource::Git { url, .. } => {
191 format!("alc_pkg_install({url:?}) to reinstall {name:?} from Git")
192 }
193 PackageSource::Installed => {
194 format!(
195 "alc_pkg_install <path-or-url> to re-record source for {name:?} \
196 (legacy 'installed' marker carries no path)"
197 )
198 }
199 PackageSource::Unknown => {
200 format!(
201 "alc_hub_reindex then alc_pkg_install <path-or-url> for {name:?} \
202 (source unknown — legacy entry)"
203 )
204 }
205 }
206}
207
208fn push_doctor_outcome(name: &str, outcome: DoctorOutcome, buckets: &mut DoctorBuckets) {
210 match outcome {
211 DoctorOutcome::Healthy => buckets.healthy.push(serde_json::json!({
212 "name": name,
213 })),
214 DoctorOutcome::SymlinkDangling { reason, suggestion } => {
215 buckets.symlink_dangling.push(serde_json::json!({
216 "name": name,
217 "kind": "symlink_dangling",
218 "reason": reason,
219 "suggestion": suggestion,
220 }))
221 }
222 DoctorOutcome::InstalledMissing { reason, suggestion } => {
223 buckets.installed_missing.push(serde_json::json!({
224 "name": name,
225 "kind": "installed_missing",
226 "reason": reason,
227 "suggestion": suggestion,
228 }))
229 }
230 DoctorOutcome::IncompletePkg {
231 missing_subs,
232 suggestion,
233 } => buckets.incomplete_pkg.push(serde_json::json!({
234 "name": name,
235 "kind": "incomplete_pkg",
236 "missing_subs": missing_subs,
237 "suggestion": suggestion,
238 })),
239 }
240}
241
242fn check_incomplete(name: &str, dest: &Path, is_symlink: bool) -> Option<DoctorOutcome> {
254 let init_lua = dest.join("init.lua");
255 let src = match std::fs::read_to_string(&init_lua) {
256 Ok(s) => s,
257 Err(e) => {
258 warn!(
259 error = %e,
260 path = %init_lua.display(),
261 "could not read init.lua for incomplete check; skipping"
262 );
263 return None;
264 }
265 };
266
267 let required_subs = extract_required_subs(&src, name);
268 if required_subs.is_empty() {
269 return None;
270 }
271
272 let missing: Vec<String> = required_subs
273 .into_iter()
274 .filter(|sub| {
275 let as_file = dest.join(format!("{sub}.lua"));
276 let as_dir = dest.join(sub).join("init.lua");
277 !as_file.exists() && !as_dir.exists()
278 })
279 .collect();
280
281 if missing.is_empty() {
282 return None;
283 }
284
285 Some(DoctorOutcome::IncompletePkg {
286 missing_subs: missing,
287 suggestion: incomplete_pkg_suggestion(name, is_symlink),
288 })
289}
290
291fn classify_installed(name: &str, entry: &ManifestEntry, pkg_dir: &Path) -> DoctorOutcome {
299 let dest = pkg_dir.join(name);
300
301 let is_symlink = dest
302 .symlink_metadata()
303 .map(|m| m.file_type().is_symlink())
304 .unwrap_or(false);
305 if is_symlink {
306 let target_alive = match dest.try_exists() {
308 Ok(v) => v,
309 Err(e) => {
310 warn!(error = %e, path = %dest.display(), "try_exists failed; treating symlink target as dead");
311 false
312 }
313 };
314 if target_alive {
315 if let Some(incomplete) = check_incomplete(name, &dest, true) {
317 return incomplete;
318 }
319 return DoctorOutcome::Healthy;
320 }
321 let link_target = match dest.read_link() {
322 Ok(t) => t.display().to_string(),
323 Err(e) => {
324 warn!(error = %e, path = %dest.display(), "read_link failed; using placeholder for dangling target");
325 "<unknown>".to_string()
326 }
327 };
328 return DoctorOutcome::SymlinkDangling {
329 reason: format!("symlink target missing: {link_target}"),
330 suggestion: symlink_dangling_suggestion(name),
331 };
332 }
333
334 if dest.exists() {
335 if let Some(incomplete) = check_incomplete(name, &dest, false) {
337 return incomplete;
338 }
339 return DoctorOutcome::Healthy;
340 }
341
342 DoctorOutcome::InstalledMissing {
343 reason: format!("installed directory missing: {}", dest.display()),
344 suggestion: installed_missing_suggestion(name, &entry.source),
345 }
346}
347
348fn run_manifest_pass(
352 manifest: &Manifest,
353 target_filter: Option<&str>,
354 pkg_dir: &Path,
355 buckets: &mut DoctorBuckets,
356) {
357 if let Some(target) = target_filter {
358 if let Some(entry) = manifest.packages.get(target) {
359 let outcome = classify_installed(target, entry, pkg_dir);
360 push_doctor_outcome(target, outcome, buckets);
361 }
362 return;
363 }
364 for (pkg_name, entry) in &manifest.packages {
365 let outcome = classify_installed(pkg_name, entry, pkg_dir);
366 push_doctor_outcome(pkg_name, outcome, buckets);
367 }
368}
369
370fn run_unattached_symlink_pass(
374 pkg_dir: &Path,
375 target_filter: Option<&str>,
376 manifest: &Manifest,
377 buckets: &mut DoctorBuckets,
378) {
379 let mut scratch: Vec<serde_json::Value> = Vec::new();
380 collect_unattached_dangling_symlinks(pkg_dir, target_filter, &manifest.packages, &mut scratch);
381 buckets.symlink_dangling.extend(scratch);
382}
383
384fn run_path_missing_pass(
388 resolved_root: Option<&Path>,
389 target_filter: Option<&str>,
390 buckets: &mut DoctorBuckets,
391) {
392 let Some(root) = resolved_root else {
393 return;
394 };
395 let mut scratch: Vec<serde_json::Value> = Vec::new();
396 collect_path_missing(
397 root,
398 target_filter,
399 "project",
400 &mut scratch,
401 ProjectPathSource::Toml,
402 );
403 collect_path_missing(
404 root,
405 target_filter,
406 "variant",
407 &mut scratch,
408 ProjectPathSource::Local,
409 );
410 buckets.path_missing.extend(scratch);
411}
412
413impl AppService {
414 pub async fn pkg_doctor(
432 &self,
433 name: Option<String>,
434 project_root: Option<String>,
435 ) -> Result<String, String> {
436 let app_dir = self.log_config.app_dir();
437 let manifest = load_manifest(&app_dir)?;
438 let pkg_dir = packages_dir(&app_dir);
439 let resolved_root = self.resolve_root(project_root.as_deref());
440 let target_filter = name.as_deref();
441
442 let mut buckets = DoctorBuckets::default();
443 run_manifest_pass(&manifest, target_filter, &pkg_dir, &mut buckets);
444 run_unattached_symlink_pass(&pkg_dir, target_filter, &manifest, &mut buckets);
445 run_path_missing_pass(resolved_root.as_deref(), target_filter, &mut buckets);
446
447 if let Some(target) = target_filter {
448 if !buckets.any_matched() {
449 return Err(format!(
450 "Package '{target}' not found in installed.json, ~/.algocline/packages/, alc.toml, or alc.local.toml"
451 ));
452 }
453 }
454
455 Ok(buckets.into_json())
456 }
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462 use std::path::PathBuf;
463
464 fn mk_entry(source: &str) -> ManifestEntry {
468 ManifestEntry {
469 version: None,
470 source: PackageSource::Path {
471 path: source.to_string(),
472 },
473 installed_at: "2026-01-01T00:00:00Z".to_string(),
474 updated_at: "2026-01-01T00:00:00Z".to_string(),
475 }
476 }
477
478 #[test]
479 fn classify_installed_healthy_dir() {
480 let tmp = tempfile::tempdir().unwrap();
481 let pkg_dir = tmp.path();
482 std::fs::create_dir(pkg_dir.join("p")).unwrap();
483
484 let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir);
485 assert!(matches!(outcome, DoctorOutcome::Healthy));
486 }
487
488 #[test]
489 fn classify_installed_missing_dir() {
490 let tmp = tempfile::tempdir().unwrap();
491 let pkg_dir = tmp.path();
492
493 let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir);
494 match outcome {
495 DoctorOutcome::InstalledMissing { reason, suggestion } => {
496 assert!(
497 reason.contains("installed directory missing"),
498 "reason = {reason}"
499 );
500 assert!(
501 suggestion.contains("alc_pkg_install"),
502 "suggestion = {suggestion}"
503 );
504 assert!(
505 suggestion.contains("/src/p"),
506 "suggestion carries source: {suggestion}"
507 );
508 }
509 _ => panic!("expected InstalledMissing"),
510 }
511 }
512
513 #[test]
514 #[cfg(unix)]
515 fn classify_installed_symlink_dangling() {
516 use std::os::unix::fs::symlink;
517
518 let tmp = tempfile::tempdir().unwrap();
519 let pkg_dir = tmp.path();
520 let dangling_target = PathBuf::from("/nonexistent/path/for/doctor_test");
521 symlink(&dangling_target, pkg_dir.join("p")).unwrap();
522
523 let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir);
524 match outcome {
525 DoctorOutcome::SymlinkDangling { reason, suggestion } => {
526 assert!(reason.contains("symlink target missing"), "{reason}");
527 assert!(suggestion.contains("alc_pkg_unlink"), "{suggestion}");
528 }
529 _ => panic!("expected SymlinkDangling"),
530 }
531 }
532
533 #[test]
534 #[cfg(unix)]
535 fn classify_installed_symlink_alive() {
536 use std::os::unix::fs::symlink;
537
538 let tmp = tempfile::tempdir().unwrap();
539 let real_target = tmp.path().join("real_target_dir");
540 std::fs::create_dir(&real_target).unwrap();
541
542 let pkg_dir = tmp.path().join("pkgs");
543 std::fs::create_dir(&pkg_dir).unwrap();
544 symlink(&real_target, pkg_dir.join("q")).unwrap();
545
546 let outcome = classify_installed("q", &mk_entry("/src/q"), &pkg_dir);
547 assert!(matches!(outcome, DoctorOutcome::Healthy));
548 }
549
550 #[test]
551 fn buckets_into_json_emits_all_five_keys() {
552 let mut b = DoctorBuckets::default();
560 b.healthy.push(serde_json::json!({"name": "h"}));
561 b.installed_missing
562 .push(serde_json::json!({"name": "i", "kind": "installed_missing"}));
563 b.symlink_dangling
564 .push(serde_json::json!({"name": "s", "kind": "symlink_dangling"}));
565 b.path_missing
566 .push(serde_json::json!({"name": "p", "kind": "path_missing"}));
567 b.incomplete_pkg
568 .push(serde_json::json!({"name": "c", "kind": "incomplete_pkg"}));
569
570 let out = b.into_json();
571 let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
572 let obj = parsed.as_object().expect("JSON object");
573 assert!(obj.contains_key("healthy"));
574 assert!(obj.contains_key("installed_missing"));
575 assert!(obj.contains_key("symlink_dangling"));
576 assert!(obj.contains_key("path_missing"));
577 assert!(obj.contains_key("incomplete_pkg"));
578 assert_eq!(obj.len(), 5, "exactly five top-level buckets: {out}");
579
580 assert_eq!(obj["healthy"][0]["name"], "h");
581 assert_eq!(obj["installed_missing"][0]["name"], "i");
582 assert_eq!(obj["symlink_dangling"][0]["name"], "s");
583 assert_eq!(obj["path_missing"][0]["name"], "p");
584 assert_eq!(obj["incomplete_pkg"][0]["name"], "c");
585 }
586
587 #[test]
588 fn any_matched_tracks_all_buckets() {
589 let mut b = DoctorBuckets::default();
590 assert!(!b.any_matched());
591 b.healthy.push(serde_json::json!({"name": "h"}));
592 assert!(b.any_matched());
593
594 let mut b = DoctorBuckets::default();
595 b.installed_missing.push(serde_json::json!({}));
596 assert!(b.any_matched());
597
598 let mut b = DoctorBuckets::default();
599 b.symlink_dangling.push(serde_json::json!({}));
600 assert!(b.any_matched());
601
602 let mut b = DoctorBuckets::default();
603 b.path_missing.push(serde_json::json!({}));
604 assert!(b.any_matched());
605
606 let mut b = DoctorBuckets::default();
607 b.incomplete_pkg.push(serde_json::json!({}));
608 assert!(b.any_matched());
609 }
610
611 #[test]
612 fn installed_missing_suggestion_shape() {
613 let git = PackageSource::Git {
614 url: "github.com/foo/bar".to_string(),
615 rev: None,
616 };
617 let s = installed_missing_suggestion("ucb", &git);
618 assert!(s.contains("alc_pkg_install"), "{s}");
619 assert!(s.contains("\"ucb\""), "{s}");
620 assert!(s.contains("github.com/foo/bar"), "{s}");
621 }
622
623 #[test]
628 fn installed_missing_suggestion_routes_bundled_to_alc_init() {
629 let bundled = PackageSource::Bundled { collection: None };
630 let s = installed_missing_suggestion("ucb", &bundled);
631 assert!(s.contains("alc_init"), "bundled must suggest alc_init: {s}");
632 assert!(
633 !s.contains("alc_pkg_install"),
634 "bundled must NOT suggest alc_pkg_install: {s}"
635 );
636 }
637
638 #[test]
645 fn installed_missing_suggestion_routes_absolute_path_to_pkg_install() {
646 let local = PackageSource::Path {
647 path: "/abs/path/to/src".to_string(),
648 };
649 let s = installed_missing_suggestion("local_pkg", &local);
650 assert!(s.contains("alc_pkg_install"), "{s}");
651 assert!(s.contains("/abs/path/to/src"), "{s}");
652 }
653
654 #[test]
658 fn installed_missing_suggestion_routes_unknown_to_reindex() {
659 let s = installed_missing_suggestion("legacy_pkg", &PackageSource::Unknown);
660 assert!(
661 s.contains("alc_hub_reindex"),
662 "Unknown must suggest alc_hub_reindex: {s}"
663 );
664 }
665
666 #[test]
669 fn extract_subs_double_quote() {
670 let src = r#"
671local M = {}
672local check = require("mypkg.check")
673local t = require("mypkg.t")
674return M
675"#;
676 let subs = extract_required_subs(src, "mypkg");
677 assert_eq!(subs, vec!["check", "t"]);
678 }
679
680 #[test]
681 fn extract_subs_single_quote() {
682 let src = "local x = require('mypkg.sub')";
683 let subs = extract_required_subs(src, "mypkg");
684 assert_eq!(subs, vec!["sub"]);
685 }
686
687 #[test]
688 fn extract_subs_ignores_other_packages() {
689 let src = r#"
690local x = require("other.sub")
691local y = require("mypkg.mine")
692"#;
693 let subs = extract_required_subs(src, "mypkg");
694 assert_eq!(subs, vec!["mine"]);
695 }
696
697 #[test]
698 fn extract_subs_deduplicates() {
699 let src = r#"
700local a = require("mypkg.check")
701local b = require("mypkg.check")
702"#;
703 let subs = extract_required_subs(src, "mypkg");
704 assert_eq!(subs, vec!["check"]);
705 }
706
707 #[test]
708 fn extract_subs_ignores_dynamic_require() {
709 let src = r#"local x = require(mod_name)"#;
711 let subs = extract_required_subs(src, "mypkg");
712 assert!(subs.is_empty(), "dynamic require must be ignored: {subs:?}");
713 }
714
715 #[test]
716 fn extract_subs_ignores_nested_dots() {
717 let src = r#"local x = require("mypkg.sub.deeper")"#;
719 let subs = extract_required_subs(src, "mypkg");
720 assert!(
721 subs.is_empty(),
722 "nested dotted require must be ignored: {subs:?}"
723 );
724 }
725
726 #[test]
727 fn extract_subs_empty_for_no_require() {
728 let src = r#"local M = {} return M"#;
729 let subs = extract_required_subs(src, "mypkg");
730 assert!(subs.is_empty());
731 }
732
733 #[test]
736 fn check_incomplete_returns_none_when_all_subs_present_as_lua() {
737 let tmp = tempfile::tempdir().unwrap();
738 let dest = tmp.path().join("mypkg");
739 std::fs::create_dir(&dest).unwrap();
740 std::fs::write(
741 dest.join("init.lua"),
742 r#"local c = require("mypkg.check") return {}"#,
743 )
744 .unwrap();
745 std::fs::write(dest.join("check.lua"), "return {}").unwrap();
746
747 assert!(check_incomplete("mypkg", &dest, false).is_none());
748 }
749
750 #[test]
751 fn check_incomplete_returns_none_when_sub_is_dir_init() {
752 let tmp = tempfile::tempdir().unwrap();
753 let dest = tmp.path().join("mypkg");
754 std::fs::create_dir(&dest).unwrap();
755 std::fs::write(
756 dest.join("init.lua"),
757 r#"local c = require("mypkg.sub") return {}"#,
758 )
759 .unwrap();
760 std::fs::create_dir(dest.join("sub")).unwrap();
762 std::fs::write(dest.join("sub").join("init.lua"), "return {}").unwrap();
763
764 assert!(check_incomplete("mypkg", &dest, false).is_none());
765 }
766
767 #[test]
768 fn check_incomplete_detects_missing_sub() {
769 let tmp = tempfile::tempdir().unwrap();
770 let dest = tmp.path().join("mypkg");
771 std::fs::create_dir(&dest).unwrap();
772 std::fs::write(
773 dest.join("init.lua"),
774 r#"
775local check = require("mypkg.check")
776local t = require("mypkg.t")
777return {}
778"#,
779 )
780 .unwrap();
781 std::fs::write(dest.join("check.lua"), "return {}").unwrap();
783
784 let outcome = check_incomplete("mypkg", &dest, false).expect("should detect incomplete");
785 match outcome {
786 DoctorOutcome::IncompletePkg {
787 missing_subs,
788 suggestion,
789 } => {
790 assert_eq!(missing_subs, vec!["t"], "missing_subs: {missing_subs:?}");
791 assert!(
792 suggestion.contains("alc_pkg_install"),
793 "non-symlink suggestion: {suggestion}"
794 );
795 }
796 _ => panic!("expected IncompletePkg"),
797 }
798 }
799
800 #[test]
801 fn check_incomplete_suggestion_uses_link_for_symlink() {
802 let tmp = tempfile::tempdir().unwrap();
803 let dest = tmp.path().join("mypkg");
804 std::fs::create_dir(&dest).unwrap();
805 std::fs::write(
806 dest.join("init.lua"),
807 r#"local x = require("mypkg.missing") return {}"#,
808 )
809 .unwrap();
810 let outcome = check_incomplete("mypkg", &dest, true).expect("should detect incomplete");
813 match outcome {
814 DoctorOutcome::IncompletePkg { suggestion, .. } => {
815 assert!(
816 suggestion.contains("alc_pkg_link"),
817 "symlink suggestion: {suggestion}"
818 );
819 }
820 _ => panic!("expected IncompletePkg"),
821 }
822 }
823
824 #[test]
825 fn check_incomplete_returns_none_when_no_init_lua() {
826 let tmp = tempfile::tempdir().unwrap();
828 let dest = tmp.path().join("mypkg");
829 std::fs::create_dir(&dest).unwrap();
830
831 assert!(check_incomplete("mypkg", &dest, false).is_none());
832 }
833
834 #[test]
835 fn classify_installed_incomplete_pkg() {
836 let tmp = tempfile::tempdir().unwrap();
838 let pkg_dir = tmp.path();
839 let dest = pkg_dir.join("mypkg");
840 std::fs::create_dir(&dest).unwrap();
841 std::fs::write(
842 dest.join("init.lua"),
843 r#"local x = require("mypkg.sub") return {}"#,
844 )
845 .unwrap();
846 let outcome = classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir);
849 match outcome {
850 DoctorOutcome::IncompletePkg {
851 missing_subs,
852 suggestion,
853 } => {
854 assert_eq!(missing_subs, vec!["sub"]);
855 assert!(suggestion.contains("alc_pkg_install"), "{suggestion}");
856 }
857 _ => panic!("expected IncompletePkg, got {outcome:?}"),
858 }
859 }
860
861 #[test]
862 fn classify_installed_healthy_when_all_subs_present() {
863 let tmp = tempfile::tempdir().unwrap();
865 let pkg_dir = tmp.path();
866 let dest = pkg_dir.join("mypkg");
867 std::fs::create_dir(&dest).unwrap();
868 std::fs::write(
869 dest.join("init.lua"),
870 r#"local x = require("mypkg.sub") return {}"#,
871 )
872 .unwrap();
873 std::fs::write(dest.join("sub.lua"), "return {}").unwrap();
874
875 let outcome = classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir);
876 assert!(
877 matches!(outcome, DoctorOutcome::Healthy),
878 "expected Healthy, got {outcome:?}"
879 );
880 }
881}