1use std::path::Path;
47
48use algocline_core::PkgEntity;
49use tracing::warn;
50
51use super::super::manifest::{load_manifest, Manifest, ManifestEntry};
52use super::super::resolve::packages_dir;
53use super::super::source::PackageSource;
54use super::super::AppService;
55use super::repair::{
56 collect_path_missing, collect_unattached_dangling_symlinks, symlink_dangling_suggestion,
57 ProjectPathSource,
58};
59
60const DOCTOR_CACHE_TTL_SECS: u64 = 3600;
64
65#[derive(Debug)]
67enum DoctorOutcome {
68 Healthy,
70 SymlinkDangling { reason: String, suggestion: String },
72 InstalledMissing { reason: String, suggestion: String },
74 IncompletePkg {
77 missing_subs: Vec<String>,
78 suggestion: String,
79 },
80 MissingMeta { reason: String, suggestion: String },
83 SpecMissing { reason: String, suggestion: String },
86}
87
88#[derive(Default)]
90struct DoctorBuckets {
91 healthy: Vec<serde_json::Value>,
92 installed_missing: Vec<serde_json::Value>,
93 symlink_dangling: Vec<serde_json::Value>,
94 path_missing: Vec<serde_json::Value>,
95 incomplete_pkg: Vec<serde_json::Value>,
96 missing_meta: Vec<serde_json::Value>,
97 missing_hub_index: Vec<serde_json::Value>,
98 spec_missing: Vec<serde_json::Value>,
99 stale_cache: Vec<serde_json::Value>,
100}
101
102impl DoctorBuckets {
103 fn any_matched(&self) -> bool {
104 !self.healthy.is_empty()
105 || !self.installed_missing.is_empty()
106 || !self.symlink_dangling.is_empty()
107 || !self.path_missing.is_empty()
108 || !self.incomplete_pkg.is_empty()
109 || !self.missing_meta.is_empty()
110 || !self.missing_hub_index.is_empty()
111 || !self.spec_missing.is_empty()
112 || !self.stale_cache.is_empty()
113 }
114
115 fn into_json(self) -> String {
116 serde_json::json!({
120 "healthy": self.healthy,
121 "incomplete_pkg": self.incomplete_pkg,
122 "installed_missing": self.installed_missing,
123 "missing_hub_index": self.missing_hub_index,
124 "missing_meta": self.missing_meta,
125 "path_missing": self.path_missing,
126 "spec_missing": self.spec_missing,
127 "stale_cache": self.stale_cache,
128 "symlink_dangling": self.symlink_dangling,
129 })
130 .to_string()
131 }
132}
133
134fn extract_required_subs(lua_src: &str, pkg_name: &str) -> Vec<String> {
148 let mut subs = Vec::new();
149 let prefix = format!("{pkg_name}.");
150 let mut remaining = lua_src;
151
152 while let Some(pos) = remaining.find("require") {
153 remaining = &remaining[pos + "require".len()..];
154
155 let trimmed = remaining.trim_start_matches([' ', '\t']);
157
158 if !trimmed.starts_with('(') {
160 continue;
161 }
162 let after_paren = &trimmed[1..];
163 let after_paren = after_paren.trim_start_matches([' ', '\t']);
164
165 let quote = match after_paren.chars().next() {
167 Some(q @ '"') | Some(q @ '\'') => q,
168 _ => continue,
169 };
170 let content = &after_paren[1..];
171 let end = match content.find(quote) {
172 Some(i) => i,
173 None => continue,
174 };
175 let module = &content[..end];
176
177 if let Some(sub) = module.strip_prefix(&prefix) {
178 if !sub.is_empty() && !sub.contains('.') {
179 subs.push(sub.to_string());
181 }
182 }
183 }
184
185 subs.sort();
186 subs.dedup();
187 subs
188}
189
190fn incomplete_pkg_suggestion(name: &str, is_symlink: bool) -> String {
193 if is_symlink {
194 format!("Re-run alc_pkg_link <path> to re-link {name:?} with the complete source directory")
195 } else {
196 format!(
197 "Run alc_pkg_install --force {name:?} to reinstall {name:?} with all submodule files"
198 )
199 }
200}
201
202fn installed_missing_suggestion(name: &str, entry_source: &PackageSource) -> String {
213 match entry_source {
214 PackageSource::Bundled { .. } => {
215 "alc_init (reinstalls bundled packages from the algocline binary)".to_string()
216 }
217 PackageSource::Path { path } => {
218 format!("alc_pkg_install({path:?}) to reinstall {name:?} from local path")
219 }
220 PackageSource::Git { url, .. } => {
221 format!("alc_pkg_install({url:?}) to reinstall {name:?} from Git")
222 }
223 PackageSource::Installed => {
224 format!(
225 "alc_pkg_install <path-or-url> to re-record source for {name:?} \
226 (legacy 'installed' marker carries no path)"
227 )
228 }
229 PackageSource::Unknown => {
230 format!(
231 "alc_hub_reindex then alc_pkg_install <path-or-url> for {name:?} \
232 (source unknown — legacy entry)"
233 )
234 }
235 }
236}
237
238fn push_doctor_outcome(name: &str, outcome: DoctorOutcome, buckets: &mut DoctorBuckets) {
240 match outcome {
241 DoctorOutcome::Healthy => buckets.healthy.push(serde_json::json!({
242 "name": name,
243 })),
244 DoctorOutcome::SymlinkDangling { reason, suggestion } => {
245 buckets.symlink_dangling.push(serde_json::json!({
246 "name": name,
247 "kind": "symlink_dangling",
248 "reason": reason,
249 "suggestion": suggestion,
250 }))
251 }
252 DoctorOutcome::InstalledMissing { reason, suggestion } => {
253 buckets.installed_missing.push(serde_json::json!({
254 "name": name,
255 "kind": "installed_missing",
256 "reason": reason,
257 "suggestion": suggestion,
258 }))
259 }
260 DoctorOutcome::IncompletePkg {
261 missing_subs,
262 suggestion,
263 } => buckets.incomplete_pkg.push(serde_json::json!({
264 "name": name,
265 "kind": "incomplete_pkg",
266 "missing_subs": missing_subs,
267 "suggestion": suggestion,
268 })),
269 DoctorOutcome::MissingMeta { reason, suggestion } => {
270 buckets.missing_meta.push(serde_json::json!({
271 "name": name,
272 "kind": "missing_meta",
273 "reason": reason,
274 "suggestion": suggestion,
275 }))
276 }
277 DoctorOutcome::SpecMissing { reason, suggestion } => {
278 buckets.spec_missing.push(serde_json::json!({
279 "name": name,
280 "kind": "spec_missing",
281 "reason": reason,
282 "suggestion": suggestion,
283 }))
284 }
285 }
286}
287
288fn check_incomplete(name: &str, dest: &Path, is_symlink: bool) -> Option<DoctorOutcome> {
300 let init_lua = dest.join("init.lua");
301 let src = match std::fs::read_to_string(&init_lua) {
302 Ok(s) => s,
303 Err(e) => {
304 warn!(
305 error = %e,
306 path = %init_lua.display(),
307 "could not read init.lua for incomplete check; skipping"
308 );
309 return None;
310 }
311 };
312
313 let required_subs = extract_required_subs(&src, name);
314 if required_subs.is_empty() {
315 return None;
316 }
317
318 let missing: Vec<String> = required_subs
319 .into_iter()
320 .filter(|sub| {
321 let as_file = dest.join(format!("{sub}.lua"));
322 let as_dir = dest.join(sub).join("init.lua");
323 !as_file.exists() && !as_dir.exists()
324 })
325 .collect();
326
327 if missing.is_empty() {
328 return None;
329 }
330
331 Some(DoctorOutcome::IncompletePkg {
332 missing_subs: missing,
333 suggestion: incomplete_pkg_suggestion(name, is_symlink),
334 })
335}
336
337fn check_missing_meta(name: &str, dest: &Path) -> Option<DoctorOutcome> {
347 let init_lua = dest.join("init.lua");
348 if PkgEntity::parse_from_init_lua(&init_lua).is_some() {
349 return None;
350 }
351 Some(DoctorOutcome::MissingMeta {
352 reason: format!("init.lua at {} lacks M.meta.name", init_lua.display()),
353 suggestion: format!(
354 "Package directory at {} lacks M.meta.name in init.lua — \
355 run alc_pkg_install --force {name:?} or fix init.lua to declare \
356 M.meta = {{ name = ..., version = ... }}",
357 dest.display()
358 ),
359 })
360}
361
362fn check_spec_missing(name: &str, dest: &Path) -> Result<Option<DoctorOutcome>, String> {
372 let spec_dir = dest.join("spec");
373 if !spec_dir.is_dir() {
374 return Ok(None);
375 }
376 let entries = std::fs::read_dir(&spec_dir).map_err(|e| {
377 format!(
378 "spec_missing: failed to read_dir {}: {e}",
379 spec_dir.display()
380 )
381 })?;
382 let mut found_spec = false;
383 for entry in entries {
384 let entry = entry.map_err(|e| format!("spec_missing: failed to read dir entry: {e}"))?;
385 let ft = entry.file_type().map_err(|e| {
386 format!(
387 "spec_missing: failed to read file_type for {}: {e}",
388 entry.path().display()
389 )
390 })?;
391 if !ft.is_file() {
392 continue;
393 }
394 let fname = entry.file_name();
395 if fname.to_string_lossy().ends_with("_spec.lua") {
396 found_spec = true;
397 break;
398 }
399 }
400 if found_spec {
401 return Ok(None);
402 }
403 Ok(Some(DoctorOutcome::SpecMissing {
404 reason: format!(
405 "spec directory at {} exists but contains zero *_spec.lua files",
406 spec_dir.display()
407 ),
408 suggestion: format!(
409 "Package {name:?} declared test intent by creating spec/ at {} — \
410 add at least one <name>_spec.lua file (mlua-lspec convention) or remove \
411 the spec/ directory to opt out of spec discipline",
412 spec_dir.display()
413 ),
414 }))
415}
416
417fn classify_installed(
425 name: &str,
426 entry: &ManifestEntry,
427 pkg_dir: &Path,
428) -> Result<DoctorOutcome, String> {
429 let dest = pkg_dir.join(name);
430
431 let is_symlink = dest
432 .symlink_metadata()
433 .map(|m| m.file_type().is_symlink())
434 .unwrap_or(false);
435 if is_symlink {
436 let target_alive = match dest.try_exists() {
438 Ok(v) => v,
439 Err(e) => {
440 warn!(error = %e, path = %dest.display(), "try_exists failed; treating symlink target as dead");
441 false
442 }
443 };
444 if target_alive {
445 if let Some(incomplete) = check_incomplete(name, &dest, true) {
447 return Ok(incomplete);
448 }
449 if let Some(mm) = check_missing_meta(name, &dest) {
450 return Ok(mm);
451 }
452 if let Some(sm) = check_spec_missing(name, &dest)? {
453 return Ok(sm);
454 }
455 return Ok(DoctorOutcome::Healthy);
456 }
457 let link_target = match dest.read_link() {
458 Ok(t) => t.display().to_string(),
459 Err(e) => {
460 warn!(error = %e, path = %dest.display(), "read_link failed; using placeholder for dangling target");
461 "<unknown>".to_string()
462 }
463 };
464 return Ok(DoctorOutcome::SymlinkDangling {
465 reason: format!("symlink target missing: {link_target}"),
466 suggestion: symlink_dangling_suggestion(name),
467 });
468 }
469
470 if dest.exists() {
471 if let Some(incomplete) = check_incomplete(name, &dest, false) {
473 return Ok(incomplete);
474 }
475 if let Some(mm) = check_missing_meta(name, &dest) {
476 return Ok(mm);
477 }
478 if let Some(sm) = check_spec_missing(name, &dest)? {
479 return Ok(sm);
480 }
481 return Ok(DoctorOutcome::Healthy);
482 }
483
484 Ok(DoctorOutcome::InstalledMissing {
485 reason: format!("installed directory missing: {}", dest.display()),
486 suggestion: installed_missing_suggestion(name, &entry.source),
487 })
488}
489
490fn run_manifest_pass(
494 manifest: &Manifest,
495 target_filter: Option<&str>,
496 pkg_dir: &Path,
497 buckets: &mut DoctorBuckets,
498) -> Result<(), String> {
499 if let Some(target) = target_filter {
500 if let Some(entry) = manifest.packages.get(target) {
501 let outcome = classify_installed(target, entry, pkg_dir)?;
502 push_doctor_outcome(target, outcome, buckets);
503 }
504 return Ok(());
505 }
506 for (pkg_name, entry) in &manifest.packages {
507 let outcome = classify_installed(pkg_name, entry, pkg_dir)?;
508 push_doctor_outcome(pkg_name, outcome, buckets);
509 }
510 Ok(())
511}
512
513fn run_unattached_symlink_pass(
517 pkg_dir: &Path,
518 target_filter: Option<&str>,
519 manifest: &Manifest,
520 buckets: &mut DoctorBuckets,
521) {
522 let mut scratch: Vec<serde_json::Value> = Vec::new();
523 collect_unattached_dangling_symlinks(pkg_dir, target_filter, &manifest.packages, &mut scratch);
524 buckets.symlink_dangling.extend(scratch);
525}
526
527fn run_path_missing_pass(
531 resolved_root: Option<&Path>,
532 target_filter: Option<&str>,
533 buckets: &mut DoctorBuckets,
534) {
535 let Some(root) = resolved_root else {
536 return;
537 };
538 let mut scratch: Vec<serde_json::Value> = Vec::new();
539 collect_path_missing(
540 root,
541 target_filter,
542 "project",
543 &mut scratch,
544 ProjectPathSource::Toml,
545 );
546 collect_path_missing(
547 root,
548 target_filter,
549 "variant",
550 &mut scratch,
551 ProjectPathSource::Local,
552 );
553 buckets.path_missing.extend(scratch);
554}
555
556fn run_hub_index_pass(root: &Path, buckets: &mut DoctorBuckets) -> Result<(), String> {
574 let mut pkg_count = 0usize;
575 let entries = std::fs::read_dir(root).map_err(|e| {
576 format!(
577 "hub_index_pass: failed to read project_root {}: {e}",
578 root.display()
579 )
580 })?;
581 for entry in entries {
582 let entry = entry.map_err(|e| format!("hub_index_pass: failed to read dir entry: {e}"))?;
583 let ft = entry
584 .file_type()
585 .map_err(|e| format!("hub_index_pass: failed to read file_type: {e}"))?;
586 if !ft.is_dir() {
587 continue;
588 }
589 let init_lua = entry.path().join("init.lua");
590 let exists = init_lua.try_exists().map_err(|e| {
591 format!(
592 "hub_index_pass: try_exists failed for {}: {e}",
593 init_lua.display()
594 )
595 })?;
596 if exists {
597 pkg_count += 1;
598 }
599 }
600 if pkg_count < 2 {
601 return Ok(());
602 }
603 let hub_index = root.join("hub_index.json");
604 let has_index = hub_index.try_exists().map_err(|e| {
605 format!(
606 "hub_index_pass: try_exists failed for {}: {e}",
607 hub_index.display()
608 )
609 })?;
610 if has_index {
611 return Ok(());
612 }
613 buckets.missing_hub_index.push(serde_json::json!({
614 "kind": "missing_hub_index",
615 "project_root": root.display().to_string(),
616 "pkg_count": pkg_count,
617 "suggestion": format!(
618 "Collection project root contains {pkg_count} package dirs but \
619 {}/hub_index.json is missing — run alc_hub_reindex --source_dir {} \
620 to generate it",
621 root.display(),
622 root.display()
623 ),
624 }));
625 Ok(())
626}
627
628fn run_stale_cache_pass(cache_dir: &Path, buckets: &mut DoctorBuckets) -> Result<(), String> {
644 let exists = cache_dir.try_exists().map_err(|e| {
645 format!(
646 "stale_cache_pass: try_exists failed for {}: {e}",
647 cache_dir.display()
648 )
649 })?;
650 if !exists {
651 return Ok(());
652 }
653 let entries = std::fs::read_dir(cache_dir).map_err(|e| {
654 format!(
655 "stale_cache_pass: failed to read_dir {}: {e}",
656 cache_dir.display()
657 )
658 })?;
659 for entry in entries {
660 let entry =
661 entry.map_err(|e| format!("stale_cache_pass: failed to read dir entry: {e}"))?;
662 let ft = entry.file_type().map_err(|e| {
663 format!(
664 "stale_cache_pass: failed to read file_type for {}: {e}",
665 entry.path().display()
666 )
667 })?;
668 if !ft.is_file() {
669 continue;
670 }
671 let path = entry.path();
672 if path.extension().and_then(|s| s.to_str()) != Some("json") {
673 continue;
674 }
675 let metadata = entry.metadata().map_err(|e| {
676 format!(
677 "stale_cache_pass: failed to read metadata for {}: {e}",
678 path.display()
679 )
680 })?;
681 let Some(modified) = metadata.modified().ok() else {
684 continue;
685 };
686 let Some(age) = modified.elapsed().ok() else {
687 continue;
688 };
689 if age.as_secs() <= DOCTOR_CACHE_TTL_SECS {
690 continue;
691 }
692 buckets.stale_cache.push(serde_json::json!({
693 "kind": "stale_cache",
694 "path": path.display().to_string(),
695 "age_secs": age.as_secs(),
696 "suggestion": format!(
697 "Run alc_hub_search to refresh stale cache (>{DOCTOR_CACHE_TTL_SECS}s old)"
698 ),
699 }));
700 }
701 Ok(())
702}
703
704impl AppService {
705 pub async fn pkg_doctor(
729 &self,
730 name: Option<String>,
731 project_root: Option<String>,
732 ) -> Result<String, String> {
733 let app_dir = self.log_config.app_dir();
734 let manifest = load_manifest(&app_dir)?;
735 let pkg_dir = packages_dir(&app_dir);
736 let resolved_root = self.resolve_root(project_root.as_deref());
737 let target_filter = name.as_deref();
738
739 let mut buckets = DoctorBuckets::default();
740 run_manifest_pass(&manifest, target_filter, &pkg_dir, &mut buckets)?;
741 run_unattached_symlink_pass(&pkg_dir, target_filter, &manifest, &mut buckets);
742 run_path_missing_pass(resolved_root.as_deref(), target_filter, &mut buckets);
743 if target_filter.is_none() {
744 run_stale_cache_pass(&app_dir.hub_cache_dir(), &mut buckets)?;
745 if let Some(ref root) = resolved_root {
746 run_hub_index_pass(root, &mut buckets)?;
747 }
748 }
749
750 if let Some(target) = target_filter {
751 if !buckets.any_matched() {
752 return Err(format!(
753 "Package '{target}' not found in installed.json, ~/.algocline/packages/, alc.toml, or alc.local.toml"
754 ));
755 }
756 }
757
758 Ok(buckets.into_json())
759 }
760}
761
762#[cfg(test)]
763mod tests {
764 use super::*;
765 use std::path::PathBuf;
766
767 fn mk_entry(source: &str) -> ManifestEntry {
771 ManifestEntry {
772 version: None,
773 source: PackageSource::Path {
774 path: source.to_string(),
775 },
776 installed_at: "2026-01-01T00:00:00Z".to_string(),
777 updated_at: "2026-01-01T00:00:00Z".to_string(),
778 }
779 }
780
781 #[test]
782 fn classify_installed_healthy_dir() {
783 let tmp = tempfile::tempdir().unwrap();
784 let pkg_dir = tmp.path();
785 let dest = pkg_dir.join("p");
786 std::fs::create_dir(&dest).unwrap();
787 std::fs::write(
789 dest.join("init.lua"),
790 "local M = {} M.meta = { name = \"p\", version = \"0.1.0\" } return M",
791 )
792 .unwrap();
793
794 let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir).expect("classify ok");
795 assert!(matches!(outcome, DoctorOutcome::Healthy));
796 }
797
798 #[test]
799 fn classify_installed_missing_dir() {
800 let tmp = tempfile::tempdir().unwrap();
801 let pkg_dir = tmp.path();
802
803 let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir).expect("classify ok");
804 match outcome {
805 DoctorOutcome::InstalledMissing { reason, suggestion } => {
806 assert!(
807 reason.contains("installed directory missing"),
808 "reason = {reason}"
809 );
810 assert!(
811 suggestion.contains("alc_pkg_install"),
812 "suggestion = {suggestion}"
813 );
814 assert!(
815 suggestion.contains("/src/p"),
816 "suggestion carries source: {suggestion}"
817 );
818 }
819 _ => panic!("expected InstalledMissing"),
820 }
821 }
822
823 #[test]
824 #[cfg(unix)]
825 fn classify_installed_symlink_dangling() {
826 use std::os::unix::fs::symlink;
827
828 let tmp = tempfile::tempdir().unwrap();
829 let pkg_dir = tmp.path();
830 let dangling_target = PathBuf::from("/nonexistent/path/for/doctor_test");
831 symlink(&dangling_target, pkg_dir.join("p")).unwrap();
832
833 let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir).expect("classify ok");
834 match outcome {
835 DoctorOutcome::SymlinkDangling { reason, suggestion } => {
836 assert!(reason.contains("symlink target missing"), "{reason}");
837 assert!(suggestion.contains("alc_pkg_unlink"), "{suggestion}");
838 }
839 _ => panic!("expected SymlinkDangling"),
840 }
841 }
842
843 #[test]
844 #[cfg(unix)]
845 fn classify_installed_symlink_alive() {
846 use std::os::unix::fs::symlink;
847
848 let tmp = tempfile::tempdir().unwrap();
849 let real_target = tmp.path().join("real_target_dir");
850 std::fs::create_dir(&real_target).unwrap();
851 std::fs::write(
853 real_target.join("init.lua"),
854 "local M = {} M.meta = { name = \"q\", version = \"0.1.0\" } return M",
855 )
856 .unwrap();
857
858 let pkg_dir = tmp.path().join("pkgs");
859 std::fs::create_dir(&pkg_dir).unwrap();
860 symlink(&real_target, pkg_dir.join("q")).unwrap();
861
862 let outcome = classify_installed("q", &mk_entry("/src/q"), &pkg_dir).expect("classify ok");
863 assert!(matches!(outcome, DoctorOutcome::Healthy));
864 }
865
866 #[test]
867 fn buckets_into_json_emits_all_nine_keys() {
868 let mut b = DoctorBuckets::default();
876 b.healthy.push(serde_json::json!({"name": "h"}));
877 b.installed_missing
878 .push(serde_json::json!({"name": "i", "kind": "installed_missing"}));
879 b.symlink_dangling
880 .push(serde_json::json!({"name": "s", "kind": "symlink_dangling"}));
881 b.path_missing
882 .push(serde_json::json!({"name": "p", "kind": "path_missing"}));
883 b.incomplete_pkg
884 .push(serde_json::json!({"name": "c", "kind": "incomplete_pkg"}));
885 b.missing_meta
886 .push(serde_json::json!({"name": "m", "kind": "missing_meta"}));
887 b.missing_hub_index
888 .push(serde_json::json!({"kind": "missing_hub_index", "project_root": "/r"}));
889 b.spec_missing
890 .push(serde_json::json!({"name": "sm", "kind": "spec_missing"}));
891 b.stale_cache
892 .push(serde_json::json!({"kind": "stale_cache", "path": "/p", "age_secs": 7200}));
893
894 let out = b.into_json();
895 let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
896 let obj = parsed.as_object().expect("JSON object");
897 assert!(obj.contains_key("healthy"));
898 assert!(obj.contains_key("installed_missing"));
899 assert!(obj.contains_key("symlink_dangling"));
900 assert!(obj.contains_key("path_missing"));
901 assert!(obj.contains_key("incomplete_pkg"));
902 assert!(obj.contains_key("missing_meta"));
903 assert!(obj.contains_key("missing_hub_index"));
904 assert!(obj.contains_key("spec_missing"));
905 assert!(obj.contains_key("stale_cache"));
906 assert_eq!(obj.len(), 9, "exactly nine top-level buckets: {out}");
907
908 assert_eq!(obj["healthy"][0]["name"], "h");
909 assert_eq!(obj["installed_missing"][0]["name"], "i");
910 assert_eq!(obj["symlink_dangling"][0]["name"], "s");
911 assert_eq!(obj["path_missing"][0]["name"], "p");
912 assert_eq!(obj["incomplete_pkg"][0]["name"], "c");
913 assert_eq!(obj["missing_meta"][0]["name"], "m");
914 assert_eq!(obj["missing_hub_index"][0]["project_root"], "/r");
915 assert_eq!(obj["spec_missing"][0]["name"], "sm");
916 assert_eq!(obj["stale_cache"][0]["path"], "/p");
917 }
918
919 #[test]
920 fn any_matched_tracks_all_buckets() {
921 let mut b = DoctorBuckets::default();
922 assert!(!b.any_matched());
923 b.healthy.push(serde_json::json!({"name": "h"}));
924 assert!(b.any_matched());
925
926 let mut b = DoctorBuckets::default();
927 b.installed_missing.push(serde_json::json!({}));
928 assert!(b.any_matched());
929
930 let mut b = DoctorBuckets::default();
931 b.symlink_dangling.push(serde_json::json!({}));
932 assert!(b.any_matched());
933
934 let mut b = DoctorBuckets::default();
935 b.path_missing.push(serde_json::json!({}));
936 assert!(b.any_matched());
937
938 let mut b = DoctorBuckets::default();
939 b.incomplete_pkg.push(serde_json::json!({}));
940 assert!(b.any_matched());
941
942 let mut b = DoctorBuckets::default();
943 b.missing_meta.push(serde_json::json!({}));
944 assert!(b.any_matched());
945
946 let mut b = DoctorBuckets::default();
947 b.missing_hub_index.push(serde_json::json!({}));
948 assert!(b.any_matched());
949
950 let mut b = DoctorBuckets::default();
951 b.spec_missing.push(serde_json::json!({}));
952 assert!(b.any_matched());
953
954 let mut b = DoctorBuckets::default();
955 b.stale_cache.push(serde_json::json!({}));
956 assert!(b.any_matched());
957 }
958
959 #[test]
963 fn check_spec_missing_returns_none_when_spec_file_present() {
964 let tmp = tempfile::tempdir().unwrap();
965 let dest = tmp.path().join("mypkg");
966 std::fs::create_dir_all(dest.join("spec")).unwrap();
967 std::fs::write(dest.join("spec/foo_spec.lua"), "return {}").unwrap();
968 let out = check_spec_missing("mypkg", &dest).expect("must not error");
969 assert!(out.is_none(), "expected None, got: {out:?}");
970 }
971
972 #[test]
974 fn check_spec_missing_detects_empty_spec_dir() {
975 let tmp = tempfile::tempdir().unwrap();
976 let dest = tmp.path().join("mypkg");
977 std::fs::create_dir_all(dest.join("spec")).unwrap();
978 let out = check_spec_missing("mypkg", &dest)
979 .expect("must not error")
980 .expect("expected SpecMissing");
981 match out {
982 DoctorOutcome::SpecMissing { reason, suggestion } => {
983 assert!(reason.contains("spec"), "reason: {reason}");
984 assert!(suggestion.contains("_spec.lua"), "suggestion: {suggestion}");
985 }
986 _ => panic!("expected SpecMissing, got {out:?}"),
987 }
988 }
989
990 #[test]
992 fn check_spec_missing_detects_spec_dir_with_only_non_spec_files() {
993 let tmp = tempfile::tempdir().unwrap();
994 let dest = tmp.path().join("mypkg");
995 std::fs::create_dir_all(dest.join("spec")).unwrap();
996 std::fs::write(dest.join("spec/helper.lua"), "return {}").unwrap();
997 std::fs::write(dest.join("spec/README.md"), "docs").unwrap();
998 let out = check_spec_missing("mypkg", &dest)
999 .expect("must not error")
1000 .expect("expected SpecMissing");
1001 assert!(matches!(out, DoctorOutcome::SpecMissing { .. }));
1002 }
1003
1004 #[test]
1006 fn check_spec_missing_silently_skips_when_spec_dir_absent() {
1007 let tmp = tempfile::tempdir().unwrap();
1008 let dest = tmp.path().join("mypkg");
1009 std::fs::create_dir_all(&dest).unwrap();
1010 let out = check_spec_missing("mypkg", &dest).expect("must not error");
1011 assert!(
1012 out.is_none(),
1013 "expected None for absent spec/, got: {out:?}"
1014 );
1015 }
1016
1017 #[test]
1020 fn run_stale_cache_pass_emits_when_file_older_than_ttl() {
1021 let tmp = tempfile::tempdir().unwrap();
1022 let cache_dir = tmp.path();
1023 let stale_file = cache_dir.join("abc123.json");
1024 std::fs::write(&stale_file, "{}").unwrap();
1025 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(7200);
1026 let times = std::fs::FileTimes::new().set_modified(past);
1027 let f = std::fs::OpenOptions::new()
1028 .write(true)
1029 .open(&stale_file)
1030 .unwrap();
1031 f.set_times(times).unwrap();
1032
1033 let mut buckets = DoctorBuckets::default();
1034 run_stale_cache_pass(cache_dir, &mut buckets).expect("must not error");
1035 assert_eq!(
1036 buckets.stale_cache.len(),
1037 1,
1038 "expected 1 stale entry: {:?}",
1039 buckets.stale_cache
1040 );
1041 let entry = &buckets.stale_cache[0];
1042 assert_eq!(entry["kind"], "stale_cache");
1043 assert!(entry["path"]
1044 .as_str()
1045 .unwrap_or("")
1046 .ends_with("abc123.json"));
1047 assert!(entry["age_secs"].as_u64().unwrap_or(0) >= 7200);
1048 }
1049
1050 #[test]
1051 fn run_stale_cache_pass_no_emit_for_fresh_file() {
1052 let tmp = tempfile::tempdir().unwrap();
1053 let cache_dir = tmp.path();
1054 let fresh_file = cache_dir.join("xyz789.json");
1055 std::fs::write(&fresh_file, "{}").unwrap();
1056 let mut buckets = DoctorBuckets::default();
1057 run_stale_cache_pass(cache_dir, &mut buckets).expect("must not error");
1058 assert!(
1059 buckets.stale_cache.is_empty(),
1060 "expected no stale entries for fresh file"
1061 );
1062 }
1063
1064 #[test]
1065 fn run_stale_cache_pass_skips_when_cache_dir_absent() {
1066 let tmp = tempfile::tempdir().unwrap();
1067 let missing_dir = tmp.path().join("nonexistent_cache");
1068 let mut buckets = DoctorBuckets::default();
1069 run_stale_cache_pass(&missing_dir, &mut buckets).expect("absent dir must skip with Ok");
1070 assert!(buckets.stale_cache.is_empty());
1071 }
1072
1073 #[test]
1074 fn run_stale_cache_pass_ignores_non_json_files() {
1075 let tmp = tempfile::tempdir().unwrap();
1076 let cache_dir = tmp.path();
1077 let garbage = cache_dir.join(".DS_Store");
1078 std::fs::write(&garbage, "garbage").unwrap();
1079 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(7200);
1080 let times = std::fs::FileTimes::new().set_modified(past);
1081 let f = std::fs::OpenOptions::new()
1082 .write(true)
1083 .open(&garbage)
1084 .unwrap();
1085 f.set_times(times).unwrap();
1086
1087 let mut buckets = DoctorBuckets::default();
1088 run_stale_cache_pass(cache_dir, &mut buckets).expect("must not error");
1089 assert!(
1090 buckets.stale_cache.is_empty(),
1091 "non-json files must be ignored"
1092 );
1093 }
1094
1095 #[test]
1100 fn classify_installed_missing_meta_when_init_lua_lacks_meta() {
1101 let tmp = tempfile::tempdir().unwrap();
1102 let pkg_dir = tmp.path();
1103 let dest = pkg_dir.join("mypkg");
1104 std::fs::create_dir(&dest).unwrap();
1105 std::fs::write(dest.join("init.lua"), "local M = {} return M").unwrap();
1107
1108 let outcome =
1109 classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
1110 match outcome {
1111 DoctorOutcome::MissingMeta { reason, suggestion } => {
1112 assert!(reason.contains("lacks M.meta.name"), "reason: {reason}");
1113 assert!(
1114 suggestion.contains("alc_pkg_install"),
1115 "suggestion: {suggestion}"
1116 );
1117 assert!(
1118 suggestion.contains("mypkg"),
1119 "suggestion carries name: {suggestion}"
1120 );
1121 }
1122 _ => panic!("expected MissingMeta, got {outcome:?}"),
1123 }
1124 }
1125
1126 #[test]
1130 fn classify_installed_missing_meta_when_init_lua_has_empty_meta_name() {
1131 let tmp = tempfile::tempdir().unwrap();
1132 let pkg_dir = tmp.path();
1133 let dest = pkg_dir.join("mypkg");
1134 std::fs::create_dir(&dest).unwrap();
1135 std::fs::write(
1137 dest.join("init.lua"),
1138 "local M = {} M.meta = { name = \"\", version = \"0.1.0\" } return M",
1139 )
1140 .unwrap();
1141
1142 let outcome =
1143 classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
1144 assert!(
1145 matches!(outcome, DoctorOutcome::MissingMeta { .. }),
1146 "expected MissingMeta for empty name, got {outcome:?}"
1147 );
1148 }
1149
1150 #[test]
1153 fn classify_installed_no_missing_meta_when_init_lua_complete() {
1154 let tmp = tempfile::tempdir().unwrap();
1155 let pkg_dir = tmp.path();
1156 let dest = pkg_dir.join("mypkg");
1157 std::fs::create_dir(&dest).unwrap();
1158 std::fs::write(
1159 dest.join("init.lua"),
1160 "local M = {} M.meta = { name = \"mypkg\", version = \"0.1.0\" } return M",
1161 )
1162 .unwrap();
1163
1164 let outcome =
1165 classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
1166 assert!(
1167 matches!(outcome, DoctorOutcome::Healthy),
1168 "expected Healthy for complete init.lua, got {outcome:?}"
1169 );
1170 }
1171
1172 #[test]
1177 fn run_hub_index_pass_emits_when_2_plus_pkgs_and_index_absent() {
1178 let tmp = tempfile::tempdir().unwrap();
1179 let root = tmp.path();
1180 for name in &["pkg_a", "pkg_b"] {
1182 let dir = root.join(name);
1183 std::fs::create_dir(&dir).unwrap();
1184 std::fs::write(dir.join("init.lua"), "return {}").unwrap();
1185 }
1186 let mut buckets = DoctorBuckets::default();
1189 run_hub_index_pass(root, &mut buckets).expect("run_hub_index_pass must not error");
1190
1191 assert_eq!(
1192 buckets.missing_hub_index.len(),
1193 1,
1194 "expected 1 missing_hub_index entry: {:?}",
1195 buckets.missing_hub_index
1196 );
1197 let entry = &buckets.missing_hub_index[0];
1198 assert_eq!(entry["kind"], "missing_hub_index");
1199 assert_eq!(entry["pkg_count"], 2);
1200 assert!(
1201 entry["suggestion"]
1202 .as_str()
1203 .unwrap_or("")
1204 .contains("alc_hub_reindex"),
1205 "suggestion: {entry}"
1206 );
1207 }
1208
1209 #[test]
1212 fn run_hub_index_pass_skips_when_only_1_pkg_dir() {
1213 let tmp = tempfile::tempdir().unwrap();
1214 let root = tmp.path();
1215 let dir = root.join("pkg_a");
1216 std::fs::create_dir(&dir).unwrap();
1217 std::fs::write(dir.join("init.lua"), "return {}").unwrap();
1218 let mut buckets = DoctorBuckets::default();
1221 run_hub_index_pass(root, &mut buckets).expect("run_hub_index_pass must not error");
1222
1223 assert!(
1224 buckets.missing_hub_index.is_empty(),
1225 "must not emit with only 1 pkg dir: {:?}",
1226 buckets.missing_hub_index
1227 );
1228 }
1229
1230 #[test]
1233 fn run_hub_index_pass_skips_when_hub_index_exists() {
1234 let tmp = tempfile::tempdir().unwrap();
1235 let root = tmp.path();
1236 for name in &["pkg_a", "pkg_b"] {
1237 let dir = root.join(name);
1238 std::fs::create_dir(&dir).unwrap();
1239 std::fs::write(dir.join("init.lua"), "return {}").unwrap();
1240 }
1241 std::fs::write(root.join("hub_index.json"), "{}").unwrap();
1243
1244 let mut buckets = DoctorBuckets::default();
1245 run_hub_index_pass(root, &mut buckets).expect("run_hub_index_pass must not error");
1246
1247 assert!(
1248 buckets.missing_hub_index.is_empty(),
1249 "must not emit when hub_index.json exists: {:?}",
1250 buckets.missing_hub_index
1251 );
1252 }
1253
1254 #[test]
1255 fn installed_missing_suggestion_shape() {
1256 let git = PackageSource::Git {
1257 url: "github.com/foo/bar".to_string(),
1258 rev: None,
1259 };
1260 let s = installed_missing_suggestion("ucb", &git);
1261 assert!(s.contains("alc_pkg_install"), "{s}");
1262 assert!(s.contains("\"ucb\""), "{s}");
1263 assert!(s.contains("github.com/foo/bar"), "{s}");
1264 }
1265
1266 #[test]
1271 fn installed_missing_suggestion_routes_bundled_to_alc_init() {
1272 let bundled = PackageSource::Bundled { collection: None };
1273 let s = installed_missing_suggestion("ucb", &bundled);
1274 assert!(s.contains("alc_init"), "bundled must suggest alc_init: {s}");
1275 assert!(
1276 !s.contains("alc_pkg_install"),
1277 "bundled must NOT suggest alc_pkg_install: {s}"
1278 );
1279 }
1280
1281 #[test]
1288 fn installed_missing_suggestion_routes_absolute_path_to_pkg_install() {
1289 let local = PackageSource::Path {
1290 path: "/abs/path/to/src".to_string(),
1291 };
1292 let s = installed_missing_suggestion("local_pkg", &local);
1293 assert!(s.contains("alc_pkg_install"), "{s}");
1294 assert!(s.contains("/abs/path/to/src"), "{s}");
1295 }
1296
1297 #[test]
1301 fn installed_missing_suggestion_routes_unknown_to_reindex() {
1302 let s = installed_missing_suggestion("legacy_pkg", &PackageSource::Unknown);
1303 assert!(
1304 s.contains("alc_hub_reindex"),
1305 "Unknown must suggest alc_hub_reindex: {s}"
1306 );
1307 }
1308
1309 #[test]
1312 fn extract_subs_double_quote() {
1313 let src = r#"
1314local M = {}
1315local check = require("mypkg.check")
1316local t = require("mypkg.t")
1317return M
1318"#;
1319 let subs = extract_required_subs(src, "mypkg");
1320 assert_eq!(subs, vec!["check", "t"]);
1321 }
1322
1323 #[test]
1324 fn extract_subs_single_quote() {
1325 let src = "local x = require('mypkg.sub')";
1326 let subs = extract_required_subs(src, "mypkg");
1327 assert_eq!(subs, vec!["sub"]);
1328 }
1329
1330 #[test]
1331 fn extract_subs_ignores_other_packages() {
1332 let src = r#"
1333local x = require("other.sub")
1334local y = require("mypkg.mine")
1335"#;
1336 let subs = extract_required_subs(src, "mypkg");
1337 assert_eq!(subs, vec!["mine"]);
1338 }
1339
1340 #[test]
1341 fn extract_subs_deduplicates() {
1342 let src = r#"
1343local a = require("mypkg.check")
1344local b = require("mypkg.check")
1345"#;
1346 let subs = extract_required_subs(src, "mypkg");
1347 assert_eq!(subs, vec!["check"]);
1348 }
1349
1350 #[test]
1351 fn extract_subs_ignores_dynamic_require() {
1352 let src = r#"local x = require(mod_name)"#;
1354 let subs = extract_required_subs(src, "mypkg");
1355 assert!(subs.is_empty(), "dynamic require must be ignored: {subs:?}");
1356 }
1357
1358 #[test]
1359 fn extract_subs_ignores_nested_dots() {
1360 let src = r#"local x = require("mypkg.sub.deeper")"#;
1362 let subs = extract_required_subs(src, "mypkg");
1363 assert!(
1364 subs.is_empty(),
1365 "nested dotted require must be ignored: {subs:?}"
1366 );
1367 }
1368
1369 #[test]
1370 fn extract_subs_empty_for_no_require() {
1371 let src = r#"local M = {} return M"#;
1372 let subs = extract_required_subs(src, "mypkg");
1373 assert!(subs.is_empty());
1374 }
1375
1376 #[test]
1379 fn check_incomplete_returns_none_when_all_subs_present_as_lua() {
1380 let tmp = tempfile::tempdir().unwrap();
1381 let dest = tmp.path().join("mypkg");
1382 std::fs::create_dir(&dest).unwrap();
1383 std::fs::write(
1384 dest.join("init.lua"),
1385 r#"local c = require("mypkg.check") return {}"#,
1386 )
1387 .unwrap();
1388 std::fs::write(dest.join("check.lua"), "return {}").unwrap();
1389
1390 assert!(check_incomplete("mypkg", &dest, false).is_none());
1391 }
1392
1393 #[test]
1394 fn check_incomplete_returns_none_when_sub_is_dir_init() {
1395 let tmp = tempfile::tempdir().unwrap();
1396 let dest = tmp.path().join("mypkg");
1397 std::fs::create_dir(&dest).unwrap();
1398 std::fs::write(
1399 dest.join("init.lua"),
1400 r#"local c = require("mypkg.sub") return {}"#,
1401 )
1402 .unwrap();
1403 std::fs::create_dir(dest.join("sub")).unwrap();
1405 std::fs::write(dest.join("sub").join("init.lua"), "return {}").unwrap();
1406
1407 assert!(check_incomplete("mypkg", &dest, false).is_none());
1408 }
1409
1410 #[test]
1411 fn check_incomplete_detects_missing_sub() {
1412 let tmp = tempfile::tempdir().unwrap();
1413 let dest = tmp.path().join("mypkg");
1414 std::fs::create_dir(&dest).unwrap();
1415 std::fs::write(
1416 dest.join("init.lua"),
1417 r#"
1418local check = require("mypkg.check")
1419local t = require("mypkg.t")
1420return {}
1421"#,
1422 )
1423 .unwrap();
1424 std::fs::write(dest.join("check.lua"), "return {}").unwrap();
1426
1427 let outcome = check_incomplete("mypkg", &dest, false).expect("should detect incomplete");
1428 match outcome {
1429 DoctorOutcome::IncompletePkg {
1430 missing_subs,
1431 suggestion,
1432 } => {
1433 assert_eq!(missing_subs, vec!["t"], "missing_subs: {missing_subs:?}");
1434 assert!(
1435 suggestion.contains("alc_pkg_install"),
1436 "non-symlink suggestion: {suggestion}"
1437 );
1438 }
1439 _ => panic!("expected IncompletePkg"),
1440 }
1441 }
1442
1443 #[test]
1444 fn check_incomplete_suggestion_uses_link_for_symlink() {
1445 let tmp = tempfile::tempdir().unwrap();
1446 let dest = tmp.path().join("mypkg");
1447 std::fs::create_dir(&dest).unwrap();
1448 std::fs::write(
1449 dest.join("init.lua"),
1450 r#"local x = require("mypkg.missing") return {}"#,
1451 )
1452 .unwrap();
1453 let outcome = check_incomplete("mypkg", &dest, true).expect("should detect incomplete");
1456 match outcome {
1457 DoctorOutcome::IncompletePkg { suggestion, .. } => {
1458 assert!(
1459 suggestion.contains("alc_pkg_link"),
1460 "symlink suggestion: {suggestion}"
1461 );
1462 }
1463 _ => panic!("expected IncompletePkg"),
1464 }
1465 }
1466
1467 #[test]
1468 fn check_incomplete_returns_none_when_no_init_lua() {
1469 let tmp = tempfile::tempdir().unwrap();
1471 let dest = tmp.path().join("mypkg");
1472 std::fs::create_dir(&dest).unwrap();
1473
1474 assert!(check_incomplete("mypkg", &dest, false).is_none());
1475 }
1476
1477 #[test]
1478 fn classify_installed_incomplete_pkg() {
1479 let tmp = tempfile::tempdir().unwrap();
1481 let pkg_dir = tmp.path();
1482 let dest = pkg_dir.join("mypkg");
1483 std::fs::create_dir(&dest).unwrap();
1484 std::fs::write(
1485 dest.join("init.lua"),
1486 r#"local x = require("mypkg.sub") return {}"#,
1487 )
1488 .unwrap();
1489 let outcome =
1492 classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
1493 match outcome {
1494 DoctorOutcome::IncompletePkg {
1495 missing_subs,
1496 suggestion,
1497 } => {
1498 assert_eq!(missing_subs, vec!["sub"]);
1499 assert!(suggestion.contains("alc_pkg_install"), "{suggestion}");
1500 }
1501 _ => panic!("expected IncompletePkg, got {outcome:?}"),
1502 }
1503 }
1504
1505 #[test]
1506 fn classify_installed_healthy_when_all_subs_present() {
1507 let tmp = tempfile::tempdir().unwrap();
1510 let pkg_dir = tmp.path();
1511 let dest = pkg_dir.join("mypkg");
1512 std::fs::create_dir(&dest).unwrap();
1513 std::fs::write(
1514 dest.join("init.lua"),
1515 "local M = {}\n\
1516 M.meta = { name = \"mypkg\", version = \"0.1.0\" }\n\
1517 local x = require(\"mypkg.sub\")\n\
1518 return M",
1519 )
1520 .unwrap();
1521 std::fs::write(dest.join("sub.lua"), "return {}").unwrap();
1522
1523 let outcome =
1524 classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
1525 assert!(
1526 matches!(outcome, DoctorOutcome::Healthy),
1527 "expected Healthy, got {outcome:?}"
1528 );
1529 }
1530}