1use std::path::Path;
39
40use tracing::warn;
41
42use super::super::manifest::{load_manifest, Manifest, ManifestEntry};
43use super::super::resolve::packages_dir;
44use super::super::source::PackageSource;
45use super::super::AppService;
46use super::repair::{
47 collect_path_missing, collect_unattached_dangling_symlinks, symlink_dangling_suggestion,
48 ProjectPathSource,
49};
50
51#[derive(Debug)]
53enum DoctorOutcome {
54 Healthy,
56 SymlinkDangling { reason: String, suggestion: String },
58 InstalledMissing { reason: String, suggestion: String },
60 IncompletePkg {
63 missing_subs: Vec<String>,
64 suggestion: String,
65 },
66}
67
68#[derive(Default)]
74struct DoctorBuckets {
75 healthy: Vec<serde_json::Value>,
76 installed_missing: Vec<serde_json::Value>,
77 symlink_dangling: Vec<serde_json::Value>,
78 path_missing: Vec<serde_json::Value>,
79 incomplete_pkg: Vec<serde_json::Value>,
80 narrative_issues: Vec<serde_json::Value>,
81}
82
83impl DoctorBuckets {
84 fn any_matched(&self) -> bool {
85 !self.healthy.is_empty()
86 || !self.installed_missing.is_empty()
87 || !self.symlink_dangling.is_empty()
88 || !self.path_missing.is_empty()
89 || !self.incomplete_pkg.is_empty()
90 || !self.narrative_issues.is_empty()
91 }
92
93 fn into_json(self) -> String {
94 serde_json::json!({
98 "healthy": self.healthy,
99 "incomplete_pkg": self.incomplete_pkg,
100 "installed_missing": self.installed_missing,
101 "narrative_issues": self.narrative_issues,
102 "symlink_dangling": self.symlink_dangling,
103 "path_missing": self.path_missing,
104 })
105 .to_string()
106 }
107}
108
109fn extract_required_subs(lua_src: &str, pkg_name: &str) -> Vec<String> {
123 let mut subs = Vec::new();
124 let prefix = format!("{pkg_name}.");
125 let mut remaining = lua_src;
126
127 while let Some(pos) = remaining.find("require") {
128 remaining = &remaining[pos + "require".len()..];
129
130 let trimmed = remaining.trim_start_matches([' ', '\t']);
132
133 if !trimmed.starts_with('(') {
135 continue;
136 }
137 let after_paren = &trimmed[1..];
138 let after_paren = after_paren.trim_start_matches([' ', '\t']);
139
140 let quote = match after_paren.chars().next() {
142 Some(q @ '"') | Some(q @ '\'') => q,
143 _ => continue,
144 };
145 let content = &after_paren[1..];
146 let end = match content.find(quote) {
147 Some(i) => i,
148 None => continue,
149 };
150 let module = &content[..end];
151
152 if let Some(sub) = module.strip_prefix(&prefix) {
153 if !sub.is_empty() && !sub.contains('.') {
154 subs.push(sub.to_string());
156 }
157 }
158 }
159
160 subs.sort();
161 subs.dedup();
162 subs
163}
164
165fn incomplete_pkg_suggestion(name: &str, is_symlink: bool) -> String {
168 if is_symlink {
169 format!("Re-run alc_pkg_link <path> to re-link {name:?} with the complete source directory")
170 } else {
171 format!(
172 "Run alc_pkg_install --force {name:?} to reinstall {name:?} with all submodule files"
173 )
174 }
175}
176
177fn installed_missing_suggestion(name: &str, entry_source: &PackageSource) -> String {
188 match entry_source {
189 PackageSource::Bundled { .. } => {
190 "alc_init (reinstalls bundled packages from the algocline binary)".to_string()
191 }
192 PackageSource::Path { path } => {
193 format!("alc_pkg_install({path:?}) to reinstall {name:?} from local path")
194 }
195 PackageSource::Git { url, .. } => {
196 format!("alc_pkg_install({url:?}) to reinstall {name:?} from Git")
197 }
198 PackageSource::Installed => {
199 format!(
200 "alc_pkg_install <path-or-url> to re-record source for {name:?} \
201 (legacy 'installed' marker carries no path)"
202 )
203 }
204 PackageSource::Unknown => {
205 format!(
206 "alc_hub_reindex then alc_pkg_install <path-or-url> for {name:?} \
207 (source unknown — legacy entry)"
208 )
209 }
210 }
211}
212
213fn push_doctor_outcome(name: &str, outcome: DoctorOutcome, buckets: &mut DoctorBuckets) {
215 match outcome {
216 DoctorOutcome::Healthy => buckets.healthy.push(serde_json::json!({
217 "name": name,
218 })),
219 DoctorOutcome::SymlinkDangling { reason, suggestion } => {
220 buckets.symlink_dangling.push(serde_json::json!({
221 "name": name,
222 "kind": "symlink_dangling",
223 "reason": reason,
224 "suggestion": suggestion,
225 }))
226 }
227 DoctorOutcome::InstalledMissing { reason, suggestion } => {
228 buckets.installed_missing.push(serde_json::json!({
229 "name": name,
230 "kind": "installed_missing",
231 "reason": reason,
232 "suggestion": suggestion,
233 }))
234 }
235 DoctorOutcome::IncompletePkg {
236 missing_subs,
237 suggestion,
238 } => buckets.incomplete_pkg.push(serde_json::json!({
239 "name": name,
240 "kind": "incomplete_pkg",
241 "missing_subs": missing_subs,
242 "suggestion": suggestion,
243 })),
244 }
245}
246
247fn check_incomplete(name: &str, dest: &Path, is_symlink: bool) -> Option<DoctorOutcome> {
259 let init_lua = dest.join("init.lua");
260 let src = match std::fs::read_to_string(&init_lua) {
261 Ok(s) => s,
262 Err(e) => {
263 warn!(
264 error = %e,
265 path = %init_lua.display(),
266 "could not read init.lua for incomplete check; skipping"
267 );
268 return None;
269 }
270 };
271
272 let required_subs = extract_required_subs(&src, name);
273 if required_subs.is_empty() {
274 return None;
275 }
276
277 let missing: Vec<String> = required_subs
278 .into_iter()
279 .filter(|sub| {
280 let as_file = dest.join(format!("{sub}.lua"));
281 let as_dir = dest.join(sub).join("init.lua");
282 !as_file.exists() && !as_dir.exists()
283 })
284 .collect();
285
286 if missing.is_empty() {
287 return None;
288 }
289
290 Some(DoctorOutcome::IncompletePkg {
291 missing_subs: missing,
292 suggestion: incomplete_pkg_suggestion(name, is_symlink),
293 })
294}
295
296fn classify_installed(name: &str, entry: &ManifestEntry, pkg_dir: &Path) -> DoctorOutcome {
304 let dest = pkg_dir.join(name);
305
306 let is_symlink = dest
307 .symlink_metadata()
308 .map(|m| m.file_type().is_symlink())
309 .unwrap_or(false);
310 if is_symlink {
311 let target_alive = match dest.try_exists() {
313 Ok(v) => v,
314 Err(e) => {
315 warn!(error = %e, path = %dest.display(), "try_exists failed; treating symlink target as dead");
316 false
317 }
318 };
319 if target_alive {
320 if let Some(incomplete) = check_incomplete(name, &dest, true) {
322 return incomplete;
323 }
324 return DoctorOutcome::Healthy;
325 }
326 let link_target = match dest.read_link() {
327 Ok(t) => t.display().to_string(),
328 Err(e) => {
329 warn!(error = %e, path = %dest.display(), "read_link failed; using placeholder for dangling target");
330 "<unknown>".to_string()
331 }
332 };
333 return DoctorOutcome::SymlinkDangling {
334 reason: format!("symlink target missing: {link_target}"),
335 suggestion: symlink_dangling_suggestion(name),
336 };
337 }
338
339 if dest.exists() {
340 if let Some(incomplete) = check_incomplete(name, &dest, false) {
342 return incomplete;
343 }
344 return DoctorOutcome::Healthy;
345 }
346
347 DoctorOutcome::InstalledMissing {
348 reason: format!("installed directory missing: {}", dest.display()),
349 suggestion: installed_missing_suggestion(name, &entry.source),
350 }
351}
352
353fn run_manifest_pass(
357 manifest: &Manifest,
358 target_filter: Option<&str>,
359 pkg_dir: &Path,
360 buckets: &mut DoctorBuckets,
361) {
362 if let Some(target) = target_filter {
363 if let Some(entry) = manifest.packages.get(target) {
364 let outcome = classify_installed(target, entry, pkg_dir);
365 push_doctor_outcome(target, outcome, buckets);
366 }
367 return;
368 }
369 for (pkg_name, entry) in &manifest.packages {
370 let outcome = classify_installed(pkg_name, entry, pkg_dir);
371 push_doctor_outcome(pkg_name, outcome, buckets);
372 }
373}
374
375fn run_unattached_symlink_pass(
379 pkg_dir: &Path,
380 target_filter: Option<&str>,
381 manifest: &Manifest,
382 buckets: &mut DoctorBuckets,
383) {
384 let mut scratch: Vec<serde_json::Value> = Vec::new();
385 collect_unattached_dangling_symlinks(pkg_dir, target_filter, &manifest.packages, &mut scratch);
386 buckets.symlink_dangling.extend(scratch);
387}
388
389fn run_path_missing_pass(
393 resolved_root: Option<&Path>,
394 target_filter: Option<&str>,
395 buckets: &mut DoctorBuckets,
396) {
397 let Some(root) = resolved_root else {
398 return;
399 };
400 let mut scratch: Vec<serde_json::Value> = Vec::new();
401 collect_path_missing(
402 root,
403 target_filter,
404 "project",
405 &mut scratch,
406 ProjectPathSource::Toml,
407 );
408 collect_path_missing(
409 root,
410 target_filter,
411 "variant",
412 &mut scratch,
413 ProjectPathSource::Local,
414 );
415 buckets.path_missing.extend(scratch);
416}
417
418impl AppService {
419 pub async fn pkg_doctor(
437 &self,
438 name: Option<String>,
439 project_root: Option<String>,
440 ) -> Result<String, String> {
441 let app_dir = self.log_config.app_dir();
442 let manifest = load_manifest(&app_dir)?;
443 let pkg_dir = packages_dir(&app_dir);
444 let resolved_root = self.resolve_root(project_root.as_deref());
445 let target_filter = name.as_deref();
446
447 let mut buckets = DoctorBuckets::default();
448 run_manifest_pass(&manifest, target_filter, &pkg_dir, &mut buckets);
449 run_unattached_symlink_pass(&pkg_dir, target_filter, &manifest, &mut buckets);
450 run_path_missing_pass(resolved_root.as_deref(), target_filter, &mut buckets);
451 self.run_narrative_pass(&pkg_dir, target_filter, &manifest, &mut buckets)
452 .await;
453
454 if let Some(target) = target_filter {
455 if !buckets.any_matched() {
456 return Err(format!(
457 "Package '{target}' not found in installed.json, ~/.algocline/packages/, alc.toml, or alc.local.toml"
458 ));
459 }
460 }
461
462 Ok(buckets.into_json())
463 }
464
465 async fn run_narrative_pass(
486 &self,
487 pkg_dir: &Path,
488 target_filter: Option<&str>,
489 manifest: &Manifest,
490 buckets: &mut DoctorBuckets,
491 ) {
492 for (name, _entry) in manifest.packages.iter() {
493 if let Some(target) = target_filter {
494 if target != name.as_str() {
495 continue;
496 }
497 }
498 let pkg_path = pkg_dir.join(name);
499 if !pkg_path.is_dir() {
500 continue;
503 }
504 let declared = match self.pkg_resolve_narrative_path(name).await {
505 Ok(opt) => opt,
506 Err(e) => {
507 warn!("pkg_doctor narrative pass: pkg '{name}' load failed: {e}");
512 continue;
513 }
514 };
515 match declared {
516 Some(rel) => {
517 let narr_path = pkg_path.join(&rel);
518 if !narr_path.is_file() {
519 buckets.narrative_issues.push(serde_json::json!({
520 "name": name,
521 "kind": "declared_missing",
522 "severity": "warn",
523 "declared_path": rel,
524 "resolved_path": narr_path.to_string_lossy(),
525 "message": format!(
526 "M.docs.narrative declares '{rel}' but the file is absent at {}",
527 narr_path.display()
528 ),
529 "suggestion": format!(
530 "Create the narrative file or update M.docs.narrative to point at an existing file."
531 ),
532 }));
533 }
534 }
535 None => {
536 let convention = pkg_path.join("narrative.md");
537 if convention.is_file() {
538 buckets.narrative_issues.push(serde_json::json!({
539 "name": name,
540 "kind": "unmigrated",
541 "severity": "info",
542 "resolved_path": convention.to_string_lossy(),
543 "message": format!(
544 "convention narrative.md exists but M.docs is not declared (#1778197753 adoption candidate)"
545 ),
546 "suggestion": "Add M.docs = { narrative = \"narrative.md\", schema_version = 1 } to init.lua to make the SSOT explicit.".to_string(),
547 }));
548 }
549 }
550 }
551 }
552 }
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558 use std::path::PathBuf;
559
560 fn mk_entry(source: &str) -> ManifestEntry {
564 ManifestEntry {
565 version: None,
566 source: PackageSource::Path {
567 path: source.to_string(),
568 },
569 installed_at: "2026-01-01T00:00:00Z".to_string(),
570 updated_at: "2026-01-01T00:00:00Z".to_string(),
571 }
572 }
573
574 #[test]
575 fn classify_installed_healthy_dir() {
576 let tmp = tempfile::tempdir().unwrap();
577 let pkg_dir = tmp.path();
578 std::fs::create_dir(pkg_dir.join("p")).unwrap();
579
580 let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir);
581 assert!(matches!(outcome, DoctorOutcome::Healthy));
582 }
583
584 #[test]
585 fn classify_installed_missing_dir() {
586 let tmp = tempfile::tempdir().unwrap();
587 let pkg_dir = tmp.path();
588
589 let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir);
590 match outcome {
591 DoctorOutcome::InstalledMissing { reason, suggestion } => {
592 assert!(
593 reason.contains("installed directory missing"),
594 "reason = {reason}"
595 );
596 assert!(
597 suggestion.contains("alc_pkg_install"),
598 "suggestion = {suggestion}"
599 );
600 assert!(
601 suggestion.contains("/src/p"),
602 "suggestion carries source: {suggestion}"
603 );
604 }
605 _ => panic!("expected InstalledMissing"),
606 }
607 }
608
609 #[test]
610 #[cfg(unix)]
611 fn classify_installed_symlink_dangling() {
612 use std::os::unix::fs::symlink;
613
614 let tmp = tempfile::tempdir().unwrap();
615 let pkg_dir = tmp.path();
616 let dangling_target = PathBuf::from("/nonexistent/path/for/doctor_test");
617 symlink(&dangling_target, pkg_dir.join("p")).unwrap();
618
619 let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir);
620 match outcome {
621 DoctorOutcome::SymlinkDangling { reason, suggestion } => {
622 assert!(reason.contains("symlink target missing"), "{reason}");
623 assert!(suggestion.contains("alc_pkg_unlink"), "{suggestion}");
624 }
625 _ => panic!("expected SymlinkDangling"),
626 }
627 }
628
629 #[test]
630 #[cfg(unix)]
631 fn classify_installed_symlink_alive() {
632 use std::os::unix::fs::symlink;
633
634 let tmp = tempfile::tempdir().unwrap();
635 let real_target = tmp.path().join("real_target_dir");
636 std::fs::create_dir(&real_target).unwrap();
637
638 let pkg_dir = tmp.path().join("pkgs");
639 std::fs::create_dir(&pkg_dir).unwrap();
640 symlink(&real_target, pkg_dir.join("q")).unwrap();
641
642 let outcome = classify_installed("q", &mk_entry("/src/q"), &pkg_dir);
643 assert!(matches!(outcome, DoctorOutcome::Healthy));
644 }
645
646 #[test]
647 fn buckets_into_json_emits_all_five_keys() {
648 let mut b = DoctorBuckets::default();
654 b.healthy.push(serde_json::json!({"name": "h"}));
655 b.installed_missing
656 .push(serde_json::json!({"name": "i", "kind": "installed_missing"}));
657 b.symlink_dangling
658 .push(serde_json::json!({"name": "s", "kind": "symlink_dangling"}));
659 b.path_missing
660 .push(serde_json::json!({"name": "p", "kind": "path_missing"}));
661 b.incomplete_pkg
662 .push(serde_json::json!({"name": "c", "kind": "incomplete_pkg"}));
663
664 let out = b.into_json();
665 let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
666 let obj = parsed.as_object().expect("JSON object");
667 assert!(obj.contains_key("healthy"));
668 assert!(obj.contains_key("installed_missing"));
669 assert!(obj.contains_key("symlink_dangling"));
670 assert!(obj.contains_key("path_missing"));
671 assert!(obj.contains_key("incomplete_pkg"));
672 assert!(obj.contains_key("narrative_issues"));
673 assert_eq!(obj.len(), 6, "exactly six top-level buckets: {out}");
676
677 assert_eq!(obj["healthy"][0]["name"], "h");
678 assert_eq!(obj["installed_missing"][0]["name"], "i");
679 assert_eq!(obj["symlink_dangling"][0]["name"], "s");
680 assert_eq!(obj["path_missing"][0]["name"], "p");
681 assert_eq!(obj["incomplete_pkg"][0]["name"], "c");
682 assert_eq!(obj["narrative_issues"], serde_json::json!([]));
684 }
685
686 #[test]
687 fn any_matched_tracks_all_buckets() {
688 let mut b = DoctorBuckets::default();
689 assert!(!b.any_matched());
690 b.healthy.push(serde_json::json!({"name": "h"}));
691 assert!(b.any_matched());
692
693 let mut b = DoctorBuckets::default();
694 b.installed_missing.push(serde_json::json!({}));
695 assert!(b.any_matched());
696
697 let mut b = DoctorBuckets::default();
698 b.symlink_dangling.push(serde_json::json!({}));
699 assert!(b.any_matched());
700
701 let mut b = DoctorBuckets::default();
702 b.path_missing.push(serde_json::json!({}));
703 assert!(b.any_matched());
704
705 let mut b = DoctorBuckets::default();
706 b.incomplete_pkg.push(serde_json::json!({}));
707 assert!(b.any_matched());
708 }
709
710 #[test]
711 fn installed_missing_suggestion_shape() {
712 let git = PackageSource::Git {
713 url: "github.com/foo/bar".to_string(),
714 rev: None,
715 };
716 let s = installed_missing_suggestion("ucb", &git);
717 assert!(s.contains("alc_pkg_install"), "{s}");
718 assert!(s.contains("\"ucb\""), "{s}");
719 assert!(s.contains("github.com/foo/bar"), "{s}");
720 }
721
722 #[test]
727 fn installed_missing_suggestion_routes_bundled_to_alc_init() {
728 let bundled = PackageSource::Bundled { collection: None };
729 let s = installed_missing_suggestion("ucb", &bundled);
730 assert!(s.contains("alc_init"), "bundled must suggest alc_init: {s}");
731 assert!(
732 !s.contains("alc_pkg_install"),
733 "bundled must NOT suggest alc_pkg_install: {s}"
734 );
735 }
736
737 #[test]
744 fn installed_missing_suggestion_routes_absolute_path_to_pkg_install() {
745 let local = PackageSource::Path {
746 path: "/abs/path/to/src".to_string(),
747 };
748 let s = installed_missing_suggestion("local_pkg", &local);
749 assert!(s.contains("alc_pkg_install"), "{s}");
750 assert!(s.contains("/abs/path/to/src"), "{s}");
751 }
752
753 #[test]
757 fn installed_missing_suggestion_routes_unknown_to_reindex() {
758 let s = installed_missing_suggestion("legacy_pkg", &PackageSource::Unknown);
759 assert!(
760 s.contains("alc_hub_reindex"),
761 "Unknown must suggest alc_hub_reindex: {s}"
762 );
763 }
764
765 #[test]
768 fn extract_subs_double_quote() {
769 let src = r#"
770local M = {}
771local check = require("mypkg.check")
772local t = require("mypkg.t")
773return M
774"#;
775 let subs = extract_required_subs(src, "mypkg");
776 assert_eq!(subs, vec!["check", "t"]);
777 }
778
779 #[test]
780 fn extract_subs_single_quote() {
781 let src = "local x = require('mypkg.sub')";
782 let subs = extract_required_subs(src, "mypkg");
783 assert_eq!(subs, vec!["sub"]);
784 }
785
786 #[test]
787 fn extract_subs_ignores_other_packages() {
788 let src = r#"
789local x = require("other.sub")
790local y = require("mypkg.mine")
791"#;
792 let subs = extract_required_subs(src, "mypkg");
793 assert_eq!(subs, vec!["mine"]);
794 }
795
796 #[test]
797 fn extract_subs_deduplicates() {
798 let src = r#"
799local a = require("mypkg.check")
800local b = require("mypkg.check")
801"#;
802 let subs = extract_required_subs(src, "mypkg");
803 assert_eq!(subs, vec!["check"]);
804 }
805
806 #[test]
807 fn extract_subs_ignores_dynamic_require() {
808 let src = r#"local x = require(mod_name)"#;
810 let subs = extract_required_subs(src, "mypkg");
811 assert!(subs.is_empty(), "dynamic require must be ignored: {subs:?}");
812 }
813
814 #[test]
815 fn extract_subs_ignores_nested_dots() {
816 let src = r#"local x = require("mypkg.sub.deeper")"#;
818 let subs = extract_required_subs(src, "mypkg");
819 assert!(
820 subs.is_empty(),
821 "nested dotted require must be ignored: {subs:?}"
822 );
823 }
824
825 #[test]
826 fn extract_subs_empty_for_no_require() {
827 let src = r#"local M = {} return M"#;
828 let subs = extract_required_subs(src, "mypkg");
829 assert!(subs.is_empty());
830 }
831
832 #[test]
835 fn check_incomplete_returns_none_when_all_subs_present_as_lua() {
836 let tmp = tempfile::tempdir().unwrap();
837 let dest = tmp.path().join("mypkg");
838 std::fs::create_dir(&dest).unwrap();
839 std::fs::write(
840 dest.join("init.lua"),
841 r#"local c = require("mypkg.check") return {}"#,
842 )
843 .unwrap();
844 std::fs::write(dest.join("check.lua"), "return {}").unwrap();
845
846 assert!(check_incomplete("mypkg", &dest, false).is_none());
847 }
848
849 #[test]
850 fn check_incomplete_returns_none_when_sub_is_dir_init() {
851 let tmp = tempfile::tempdir().unwrap();
852 let dest = tmp.path().join("mypkg");
853 std::fs::create_dir(&dest).unwrap();
854 std::fs::write(
855 dest.join("init.lua"),
856 r#"local c = require("mypkg.sub") return {}"#,
857 )
858 .unwrap();
859 std::fs::create_dir(dest.join("sub")).unwrap();
861 std::fs::write(dest.join("sub").join("init.lua"), "return {}").unwrap();
862
863 assert!(check_incomplete("mypkg", &dest, false).is_none());
864 }
865
866 #[test]
867 fn check_incomplete_detects_missing_sub() {
868 let tmp = tempfile::tempdir().unwrap();
869 let dest = tmp.path().join("mypkg");
870 std::fs::create_dir(&dest).unwrap();
871 std::fs::write(
872 dest.join("init.lua"),
873 r#"
874local check = require("mypkg.check")
875local t = require("mypkg.t")
876return {}
877"#,
878 )
879 .unwrap();
880 std::fs::write(dest.join("check.lua"), "return {}").unwrap();
882
883 let outcome = check_incomplete("mypkg", &dest, false).expect("should detect incomplete");
884 match outcome {
885 DoctorOutcome::IncompletePkg {
886 missing_subs,
887 suggestion,
888 } => {
889 assert_eq!(missing_subs, vec!["t"], "missing_subs: {missing_subs:?}");
890 assert!(
891 suggestion.contains("alc_pkg_install"),
892 "non-symlink suggestion: {suggestion}"
893 );
894 }
895 _ => panic!("expected IncompletePkg"),
896 }
897 }
898
899 #[test]
900 fn check_incomplete_suggestion_uses_link_for_symlink() {
901 let tmp = tempfile::tempdir().unwrap();
902 let dest = tmp.path().join("mypkg");
903 std::fs::create_dir(&dest).unwrap();
904 std::fs::write(
905 dest.join("init.lua"),
906 r#"local x = require("mypkg.missing") return {}"#,
907 )
908 .unwrap();
909 let outcome = check_incomplete("mypkg", &dest, true).expect("should detect incomplete");
912 match outcome {
913 DoctorOutcome::IncompletePkg { suggestion, .. } => {
914 assert!(
915 suggestion.contains("alc_pkg_link"),
916 "symlink suggestion: {suggestion}"
917 );
918 }
919 _ => panic!("expected IncompletePkg"),
920 }
921 }
922
923 #[test]
924 fn check_incomplete_returns_none_when_no_init_lua() {
925 let tmp = tempfile::tempdir().unwrap();
927 let dest = tmp.path().join("mypkg");
928 std::fs::create_dir(&dest).unwrap();
929
930 assert!(check_incomplete("mypkg", &dest, false).is_none());
931 }
932
933 #[test]
934 fn classify_installed_incomplete_pkg() {
935 let tmp = tempfile::tempdir().unwrap();
937 let pkg_dir = tmp.path();
938 let dest = pkg_dir.join("mypkg");
939 std::fs::create_dir(&dest).unwrap();
940 std::fs::write(
941 dest.join("init.lua"),
942 r#"local x = require("mypkg.sub") return {}"#,
943 )
944 .unwrap();
945 let outcome = classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir);
948 match outcome {
949 DoctorOutcome::IncompletePkg {
950 missing_subs,
951 suggestion,
952 } => {
953 assert_eq!(missing_subs, vec!["sub"]);
954 assert!(suggestion.contains("alc_pkg_install"), "{suggestion}");
955 }
956 _ => panic!("expected IncompletePkg, got {outcome:?}"),
957 }
958 }
959
960 #[test]
961 fn classify_installed_healthy_when_all_subs_present() {
962 let tmp = tempfile::tempdir().unwrap();
964 let pkg_dir = tmp.path();
965 let dest = pkg_dir.join("mypkg");
966 std::fs::create_dir(&dest).unwrap();
967 std::fs::write(
968 dest.join("init.lua"),
969 r#"local x = require("mypkg.sub") return {}"#,
970 )
971 .unwrap();
972 std::fs::write(dest.join("sub.lua"), "return {}").unwrap();
973
974 let outcome = classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir);
975 assert!(
976 matches!(outcome, DoctorOutcome::Healthy),
977 "expected Healthy, got {outcome:?}"
978 );
979 }
980}