1use std::collections::HashSet;
49use std::path::{Path, PathBuf};
50
51use algocline_core::{PkgEntity, TypeSource};
52use tracing::warn;
53
54use super::super::alc_toml::{load_alc_local_toml, load_alc_toml, PackageDep};
55use super::super::manifest::{load_manifest, Manifest, ManifestEntry};
56use super::super::resolve::packages_dir;
57use super::super::source::PackageSource;
58use super::super::AppService;
59use super::repair::{
60 collect_path_missing, collect_unattached_dangling_symlinks, collect_unregistered_pkg_dirs,
61 symlink_dangling_suggestion, AliveBucket, ProjectPathSource,
62};
63
64pub(super) const UNMARKED_LIBRARY_SUGGESTION: &str =
69 "Add M.meta.type = \"library\" to declare this package as a library \
70 (auto-detect classifies it as such, but explicit declaration is recommended \
71 for clarity and tooling).";
72
73const DOCTOR_CACHE_TTL_SECS: u64 = 3600;
77
78#[derive(Debug)]
80enum DoctorOutcome {
81 Healthy,
83 SymlinkDangling { reason: String, suggestion: String },
85 InstalledMissing { reason: String, suggestion: String },
87 IncompletePkg {
90 missing_subs: Vec<String>,
91 suggestion: String,
92 },
93 MissingMeta { reason: String, suggestion: String },
96 SpecMissing { reason: String, suggestion: String },
99 UnmarkedLibrary { suggestion: String },
103}
104
105#[derive(Default)]
107struct DoctorBuckets {
108 healthy: Vec<serde_json::Value>,
109 installed_missing: Vec<serde_json::Value>,
110 symlink_dangling: Vec<serde_json::Value>,
111 path_missing: Vec<serde_json::Value>,
112 incomplete_pkg: Vec<serde_json::Value>,
113 missing_meta: Vec<serde_json::Value>,
114 missing_hub_index: Vec<serde_json::Value>,
115 spec_missing: Vec<serde_json::Value>,
116 stale_cache: Vec<serde_json::Value>,
117 unregistered_pkg: Vec<serde_json::Value>,
124 unmarked_library: Vec<serde_json::Value>,
132}
133
134impl DoctorBuckets {
135 fn any_matched(&self) -> bool {
136 !self.healthy.is_empty()
137 || !self.installed_missing.is_empty()
138 || !self.symlink_dangling.is_empty()
139 || !self.path_missing.is_empty()
140 || !self.incomplete_pkg.is_empty()
141 || !self.missing_meta.is_empty()
142 || !self.missing_hub_index.is_empty()
143 || !self.spec_missing.is_empty()
144 || !self.stale_cache.is_empty()
145 || !self.unregistered_pkg.is_empty()
146 || !self.unmarked_library.is_empty()
147 }
148
149 fn into_json(self) -> String {
150 serde_json::json!({
154 "healthy": self.healthy,
155 "incomplete_pkg": self.incomplete_pkg,
156 "installed_missing": self.installed_missing,
157 "missing_hub_index": self.missing_hub_index,
158 "missing_meta": self.missing_meta,
159 "path_missing": self.path_missing,
160 "spec_missing": self.spec_missing,
161 "stale_cache": self.stale_cache,
162 "symlink_dangling": self.symlink_dangling,
163 "unmarked_library": self.unmarked_library,
164 "unregistered_pkg": self.unregistered_pkg,
165 })
166 .to_string()
167 }
168}
169
170fn extract_required_subs(lua_src: &str, pkg_name: &str) -> Vec<String> {
184 let mut subs = Vec::new();
185 let prefix = format!("{pkg_name}.");
186 let mut remaining = lua_src;
187
188 while let Some(pos) = remaining.find("require") {
189 remaining = &remaining[pos + "require".len()..];
190
191 let trimmed = remaining.trim_start_matches([' ', '\t']);
193
194 if !trimmed.starts_with('(') {
196 continue;
197 }
198 let after_paren = &trimmed[1..];
199 let after_paren = after_paren.trim_start_matches([' ', '\t']);
200
201 let quote = match after_paren.chars().next() {
203 Some(q @ '"') | Some(q @ '\'') => q,
204 _ => continue,
205 };
206 let content = &after_paren[1..];
207 let end = match content.find(quote) {
208 Some(i) => i,
209 None => continue,
210 };
211 let module = &content[..end];
212
213 if let Some(sub) = module.strip_prefix(&prefix) {
214 if !sub.is_empty() && !sub.contains('.') {
215 subs.push(sub.to_string());
217 }
218 }
219 }
220
221 subs.sort();
222 subs.dedup();
223 subs
224}
225
226fn incomplete_pkg_suggestion(name: &str, is_symlink: bool) -> String {
229 if is_symlink {
230 format!("Re-run alc_pkg_link <path> to re-link {name:?} with the complete source directory")
231 } else {
232 format!(
233 "Run alc_pkg_install --force {name:?} to reinstall {name:?} with all submodule files"
234 )
235 }
236}
237
238fn installed_missing_suggestion(name: &str, entry_source: &PackageSource) -> String {
249 match entry_source {
250 PackageSource::Bundled { .. } => {
251 "alc_init (reinstalls bundled packages from the algocline binary)".to_string()
252 }
253 PackageSource::Path { path } => {
254 format!("alc_pkg_install({path:?}) to reinstall {name:?} from local path")
255 }
256 PackageSource::Git { url, .. } => {
257 format!("alc_pkg_install({url:?}) to reinstall {name:?} from Git")
258 }
259 PackageSource::Installed => {
260 format!(
261 "alc_pkg_install <path-or-url> to re-record source for {name:?} \
262 (legacy 'installed' marker carries no path)"
263 )
264 }
265 PackageSource::Unknown => {
266 format!(
267 "alc_hub_reindex then alc_pkg_install <path-or-url> for {name:?} \
268 (source unknown — legacy entry)"
269 )
270 }
271 }
272}
273
274fn push_doctor_outcome(name: &str, outcome: DoctorOutcome, buckets: &mut DoctorBuckets) {
276 match outcome {
277 DoctorOutcome::Healthy => buckets.healthy.push(serde_json::json!({
278 "name": name,
279 })),
280 DoctorOutcome::SymlinkDangling { reason, suggestion } => {
281 buckets.symlink_dangling.push(serde_json::json!({
282 "name": name,
283 "kind": "symlink_dangling",
284 "reason": reason,
285 "suggestion": suggestion,
286 }))
287 }
288 DoctorOutcome::InstalledMissing { reason, suggestion } => {
289 buckets.installed_missing.push(serde_json::json!({
290 "name": name,
291 "kind": "installed_missing",
292 "reason": reason,
293 "suggestion": suggestion,
294 }))
295 }
296 DoctorOutcome::IncompletePkg {
297 missing_subs,
298 suggestion,
299 } => buckets.incomplete_pkg.push(serde_json::json!({
300 "name": name,
301 "kind": "incomplete_pkg",
302 "missing_subs": missing_subs,
303 "suggestion": suggestion,
304 })),
305 DoctorOutcome::MissingMeta { reason, suggestion } => {
306 buckets.missing_meta.push(serde_json::json!({
307 "name": name,
308 "kind": "missing_meta",
309 "reason": reason,
310 "suggestion": suggestion,
311 }))
312 }
313 DoctorOutcome::SpecMissing { reason, suggestion } => {
314 buckets.spec_missing.push(serde_json::json!({
315 "name": name,
316 "kind": "spec_missing",
317 "reason": reason,
318 "suggestion": suggestion,
319 }))
320 }
321 DoctorOutcome::UnmarkedLibrary { suggestion } => {
322 buckets.unmarked_library.push(serde_json::json!({
323 "name": name,
324 "kind": "unmarked_library",
325 "suggestion": suggestion,
326 }))
327 }
328 }
329}
330
331fn check_incomplete(name: &str, dest: &Path, is_symlink: bool) -> Option<DoctorOutcome> {
343 let init_lua = dest.join("init.lua");
344 let src = match std::fs::read_to_string(&init_lua) {
345 Ok(s) => s,
346 Err(e) => {
347 warn!(
348 error = %e,
349 path = %init_lua.display(),
350 "could not read init.lua for incomplete check; skipping"
351 );
352 return None;
353 }
354 };
355
356 let required_subs = extract_required_subs(&src, name);
357 if required_subs.is_empty() {
358 return None;
359 }
360
361 let missing: Vec<String> = required_subs
362 .into_iter()
363 .filter(|sub| {
364 let as_file = dest.join(format!("{sub}.lua"));
365 let as_dir = dest.join(sub).join("init.lua");
366 !as_file.exists() && !as_dir.exists()
367 })
368 .collect();
369
370 if missing.is_empty() {
371 return None;
372 }
373
374 Some(DoctorOutcome::IncompletePkg {
375 missing_subs: missing,
376 suggestion: incomplete_pkg_suggestion(name, is_symlink),
377 })
378}
379
380fn check_spec_missing(name: &str, dest: &Path) -> Result<Option<DoctorOutcome>, String> {
390 let spec_dir = dest.join("spec");
391 if !spec_dir.is_dir() {
392 return Ok(None);
393 }
394 let entries = std::fs::read_dir(&spec_dir).map_err(|e| {
395 format!(
396 "spec_missing: failed to read_dir {}: {e}",
397 spec_dir.display()
398 )
399 })?;
400 let mut found_spec = false;
401 for entry in entries {
402 let entry = entry.map_err(|e| format!("spec_missing: failed to read dir entry: {e}"))?;
403 let ft = entry.file_type().map_err(|e| {
404 format!(
405 "spec_missing: failed to read file_type for {}: {e}",
406 entry.path().display()
407 )
408 })?;
409 if !ft.is_file() {
410 continue;
411 }
412 let fname = entry.file_name();
413 if fname.to_string_lossy().ends_with("_spec.lua") {
414 found_spec = true;
415 break;
416 }
417 }
418 if found_spec {
419 return Ok(None);
420 }
421 Ok(Some(DoctorOutcome::SpecMissing {
422 reason: format!(
423 "spec directory at {} exists but contains zero *_spec.lua files",
424 spec_dir.display()
425 ),
426 suggestion: format!(
427 "Package {name:?} declared test intent by creating spec/ at {} — \
428 add at least one <name>_spec.lua file (mlua-lspec convention) or remove \
429 the spec/ directory to opt out of spec discipline",
430 spec_dir.display()
431 ),
432 }))
433}
434
435fn classify_installed(
455 name: &str,
456 entry: &ManifestEntry,
457 pkg_dir: &Path,
458) -> Result<DoctorOutcome, String> {
459 let dest = pkg_dir.join(name);
460
461 let is_symlink = dest
462 .symlink_metadata()
463 .map(|m| m.file_type().is_symlink())
464 .unwrap_or(false);
465 if is_symlink {
466 let target_alive = match dest.try_exists() {
468 Ok(v) => v,
469 Err(e) => {
470 warn!(error = %e, path = %dest.display(), "try_exists failed; treating symlink target as dead");
471 false
472 }
473 };
474 if !target_alive {
475 let link_target = match dest.read_link() {
476 Ok(t) => t.display().to_string(),
477 Err(e) => {
478 warn!(error = %e, path = %dest.display(), "read_link failed; using placeholder for dangling target");
479 "<unknown>".to_string()
480 }
481 };
482 return Ok(DoctorOutcome::SymlinkDangling {
483 reason: format!("symlink target missing: {link_target}"),
484 suggestion: symlink_dangling_suggestion(name),
485 });
486 }
487 let init_lua = dest.join("init.lua");
489 let entity = PkgEntity::parse_from_init_lua(&init_lua);
490 if let Some(incomplete) = check_incomplete(name, &dest, true) {
491 return Ok(incomplete);
492 }
493 if entity.is_none() {
494 return Ok(DoctorOutcome::MissingMeta {
495 reason: format!("init.lua at {} lacks M.meta.name", init_lua.display()),
496 suggestion: format!(
497 "Package directory at {} lacks M.meta.name in init.lua — \
498 run alc_pkg_install --force {name:?} or fix init.lua to declare \
499 M.meta = {{ name = ..., version = ... }}",
500 dest.display()
501 ),
502 });
503 }
504 if let Some(entity) = entity {
506 if let Some(sm) = check_spec_missing(name, &dest)? {
507 return Ok(sm);
508 }
509 if entity.type_source == Some(TypeSource::AutoDetectedLibrary) {
510 return Ok(DoctorOutcome::UnmarkedLibrary {
511 suggestion: UNMARKED_LIBRARY_SUGGESTION.to_string(),
512 });
513 }
514 }
515 return Ok(DoctorOutcome::Healthy);
516 }
517
518 if dest.exists() {
519 let init_lua = dest.join("init.lua");
521 let entity = PkgEntity::parse_from_init_lua(&init_lua);
522 if let Some(incomplete) = check_incomplete(name, &dest, false) {
523 return Ok(incomplete);
524 }
525 if entity.is_none() {
526 return Ok(DoctorOutcome::MissingMeta {
527 reason: format!("init.lua at {} lacks M.meta.name", init_lua.display()),
528 suggestion: format!(
529 "Package directory at {} lacks M.meta.name in init.lua — \
530 run alc_pkg_install --force {name:?} or fix init.lua to declare \
531 M.meta = {{ name = ..., version = ... }}",
532 dest.display()
533 ),
534 });
535 }
536 if let Some(entity) = entity {
538 if let Some(sm) = check_spec_missing(name, &dest)? {
539 return Ok(sm);
540 }
541 if entity.type_source == Some(TypeSource::AutoDetectedLibrary) {
542 return Ok(DoctorOutcome::UnmarkedLibrary {
543 suggestion: UNMARKED_LIBRARY_SUGGESTION.to_string(),
544 });
545 }
546 }
547 return Ok(DoctorOutcome::Healthy);
548 }
549
550 Ok(DoctorOutcome::InstalledMissing {
551 reason: format!("installed directory missing: {}", dest.display()),
552 suggestion: installed_missing_suggestion(name, &entry.source),
553 })
554}
555
556fn run_manifest_pass(
560 manifest: &Manifest,
561 target_filter: Option<&str>,
562 pkg_dir: &Path,
563 buckets: &mut DoctorBuckets,
564) -> Result<(), String> {
565 if let Some(target) = target_filter {
566 if let Some(entry) = manifest.packages.get(target) {
567 let outcome = classify_installed(target, entry, pkg_dir)?;
568 push_doctor_outcome(target, outcome, buckets);
569 }
570 return Ok(());
571 }
572 for (pkg_name, entry) in &manifest.packages {
573 let outcome = classify_installed(pkg_name, entry, pkg_dir)?;
574 push_doctor_outcome(pkg_name, outcome, buckets);
575 }
576 Ok(())
577}
578
579fn run_unattached_symlink_pass(
583 pkg_dir: &Path,
584 target_filter: Option<&str>,
585 manifest: &Manifest,
586 buckets: &mut DoctorBuckets,
587) {
588 let mut scratch: Vec<serde_json::Value> = Vec::new();
589 collect_unattached_dangling_symlinks(pkg_dir, target_filter, &manifest.packages, &mut scratch);
590 buckets.symlink_dangling.extend(scratch);
591}
592
593fn run_path_missing_pass(
597 resolved_root: Option<&Path>,
598 target_filter: Option<&str>,
599 buckets: &mut DoctorBuckets,
600) {
601 let Some(root) = resolved_root else {
602 return;
603 };
604 let mut scratch: Vec<serde_json::Value> = Vec::new();
605 collect_path_missing(
606 root,
607 target_filter,
608 "project",
609 &mut scratch,
610 ProjectPathSource::Toml,
611 );
612 collect_path_missing(
613 root,
614 target_filter,
615 "variant",
616 &mut scratch,
617 ProjectPathSource::Local,
618 );
619 buckets.path_missing.extend(scratch);
620}
621
622fn run_hub_index_pass(root: &Path, buckets: &mut DoctorBuckets) -> Result<(), String> {
640 let mut pkg_count = 0usize;
641 let entries = std::fs::read_dir(root).map_err(|e| {
642 format!(
643 "hub_index_pass: failed to read project_root {}: {e}",
644 root.display()
645 )
646 })?;
647 for entry in entries {
648 let entry = entry.map_err(|e| format!("hub_index_pass: failed to read dir entry: {e}"))?;
649 let ft = entry
650 .file_type()
651 .map_err(|e| format!("hub_index_pass: failed to read file_type: {e}"))?;
652 if !ft.is_dir() {
653 continue;
654 }
655 let init_lua = entry.path().join("init.lua");
656 let exists = init_lua.try_exists().map_err(|e| {
657 format!(
658 "hub_index_pass: try_exists failed for {}: {e}",
659 init_lua.display()
660 )
661 })?;
662 if exists {
663 pkg_count += 1;
664 }
665 }
666 if pkg_count < 2 {
667 return Ok(());
668 }
669 let hub_index = root.join("hub_index.json");
670 let has_index = hub_index.try_exists().map_err(|e| {
671 format!(
672 "hub_index_pass: try_exists failed for {}: {e}",
673 hub_index.display()
674 )
675 })?;
676 if has_index {
677 return Ok(());
678 }
679 buckets.missing_hub_index.push(serde_json::json!({
680 "kind": "missing_hub_index",
681 "project_root": root.display().to_string(),
682 "pkg_count": pkg_count,
683 "suggestion": format!(
684 "Collection project root contains {pkg_count} package dirs but \
685 {}/hub_index.json is missing — run alc_hub_reindex --source_dir {} \
686 to generate it",
687 root.display(),
688 root.display()
689 ),
690 }));
691 Ok(())
692}
693
694fn run_stale_cache_pass(cache_dir: &Path, buckets: &mut DoctorBuckets) -> Result<(), String> {
710 let exists = cache_dir.try_exists().map_err(|e| {
711 format!(
712 "stale_cache_pass: try_exists failed for {}: {e}",
713 cache_dir.display()
714 )
715 })?;
716 if !exists {
717 return Ok(());
718 }
719 let entries = std::fs::read_dir(cache_dir).map_err(|e| {
720 format!(
721 "stale_cache_pass: failed to read_dir {}: {e}",
722 cache_dir.display()
723 )
724 })?;
725 for entry in entries {
726 let entry =
727 entry.map_err(|e| format!("stale_cache_pass: failed to read dir entry: {e}"))?;
728 let ft = entry.file_type().map_err(|e| {
729 format!(
730 "stale_cache_pass: failed to read file_type for {}: {e}",
731 entry.path().display()
732 )
733 })?;
734 if !ft.is_file() {
735 continue;
736 }
737 let path = entry.path();
738 if path.extension().and_then(|s| s.to_str()) != Some("json") {
739 continue;
740 }
741 let metadata = entry.metadata().map_err(|e| {
742 format!(
743 "stale_cache_pass: failed to read metadata for {}: {e}",
744 path.display()
745 )
746 })?;
747 let Some(modified) = metadata.modified().ok() else {
750 continue;
751 };
752 let Some(age) = modified.elapsed().ok() else {
753 continue;
754 };
755 if age.as_secs() <= DOCTOR_CACHE_TTL_SECS {
756 continue;
757 }
758 buckets.stale_cache.push(serde_json::json!({
759 "kind": "stale_cache",
760 "path": path.display().to_string(),
761 "age_secs": age.as_secs(),
762 "suggestion": format!(
763 "Run alc_hub_search to refresh stale cache (>{DOCTOR_CACHE_TTL_SECS}s old)"
764 ),
765 }));
766 }
767 Ok(())
768}
769
770fn run_unregistered_pkg_pass(
789 pkg_dir: &Path,
790 registered: &HashSet<String>,
791 registered_paths: &[PathBuf],
792 target_filter: Option<&str>,
793 buckets: &mut DoctorBuckets,
794) -> Result<(), String> {
795 let found =
796 collect_unregistered_pkg_dirs(pkg_dir, registered, registered_paths, target_filter)?;
797 buckets.unregistered_pkg.extend(found);
798 Ok(())
799}
800
801impl AppService {
802 async fn run_alive_unregistered_symlink_pass(
828 &self,
829 pkg_dir: &Path,
830 registered: &HashSet<String>,
831 registered_paths: &[PathBuf],
832 target_filter: Option<&str>,
833 buckets: &mut DoctorBuckets,
834 ) -> Result<(), String> {
835 let found = self
836 .collect_alive_unregistered_symlinks(
837 pkg_dir,
838 registered,
839 registered_paths,
840 target_filter,
841 )
842 .await?;
843 for (name, bucket) in found {
844 match bucket {
845 AliveBucket::UnmarkedLibrary => {
846 buckets.unmarked_library.push(serde_json::json!({
847 "name": name,
848 "kind": "unmarked_library",
849 "suggestion": UNMARKED_LIBRARY_SUGGESTION,
850 }));
851 }
852 AliveBucket::Unregistered => {
853 let abs_path = pkg_dir.join(&name).display().to_string();
854 let suggestion = serde_json::json!([
855 format!(
856 "If this pkg was scaffolded outside `alc_pkg_scaffold` and you want it installed: \
857 `alc_pkg_install --force {abs_path}` (re-copy + register in installed.json)"
858 ),
859 format!(
860 "If you are actively iterating on this pkg in-tree: \
861 `alc_pkg_link {abs_path}` (symlink-based, no copy)"
862 ),
863 format!("If this dir is stale/abandoned: `rm -rf {abs_path}` to clean it up"),
864 "Note: source is unknown — git URL cannot be inferred from the bare directory. \
865 Re-record via one of the above."
866 .to_string(),
867 ]);
868 buckets.unregistered_pkg.push(serde_json::json!({
869 "name": name,
870 "kind": "unregistered_pkg",
871 "source": "unknown",
872 "reason": format!(
873 "alive symlink with init.lua exists but is not registered in \
874 installed.json, alc.toml, or alc.local.toml: <symlink path: '{abs_path}'>"
875 ),
876 "suggestion": suggestion,
877 }));
878 }
879 }
880 }
881 Ok(())
882 }
883}
884
885impl AppService {
886 pub async fn pkg_doctor(
910 &self,
911 name: Option<String>,
912 project_root: Option<String>,
913 ) -> Result<String, String> {
914 let app_dir = self.log_config.app_dir();
915 let manifest = load_manifest(&app_dir)?;
916 let pkg_dir = packages_dir(&app_dir);
917 let resolved_root = self.resolve_root(project_root.as_deref());
918 let target_filter = name.as_deref();
919
920 let mut registered: HashSet<String> = manifest.packages.keys().cloned().collect();
925 let mut registered_paths: Vec<PathBuf> = Vec::new();
926
927 if let Some(ref root) = resolved_root {
928 if let Some(toml_data) = load_alc_toml(root)? {
930 for (name, dep) in &toml_data.packages {
931 registered.insert(name.clone());
932 if let PackageDep::Path { path, .. } = dep {
933 let raw = std::path::Path::new(path);
934 let abs = if raw.is_absolute() {
935 raw.to_path_buf()
936 } else {
937 root.join(raw)
938 };
939 match abs.canonicalize() {
940 Ok(c) => registered_paths.push(c),
941 Err(e) => {
942 tracing::warn!(
948 "pkg: cannot canonicalize alc.toml path entry \
949 for '{}' ({}): {e}",
950 name,
951 abs.display()
952 );
953 }
954 }
955 }
956 }
957 }
958 if let Some(local_data) = load_alc_local_toml(root)? {
960 for (name, dep) in &local_data.packages {
961 registered.insert(name.clone());
962 if let PackageDep::Path { path, .. } = dep {
963 let raw = std::path::Path::new(path);
964 let abs = if raw.is_absolute() {
965 raw.to_path_buf()
966 } else {
967 root.join(raw)
968 };
969 match abs.canonicalize() {
970 Ok(c) => registered_paths.push(c),
971 Err(e) => {
972 tracing::warn!(
973 "pkg: cannot canonicalize alc.local.toml path entry \
974 for '{}' ({}): {e}",
975 name,
976 abs.display()
977 );
978 }
979 }
980 }
981 }
982 }
983 }
984
985 let mut buckets = DoctorBuckets::default();
986 run_manifest_pass(&manifest, target_filter, &pkg_dir, &mut buckets)?;
987 run_unattached_symlink_pass(&pkg_dir, target_filter, &manifest, &mut buckets);
988 run_path_missing_pass(resolved_root.as_deref(), target_filter, &mut buckets);
989 run_unregistered_pkg_pass(
990 &pkg_dir,
991 ®istered,
992 ®istered_paths,
993 target_filter,
994 &mut buckets,
995 )?;
996 self.run_alive_unregistered_symlink_pass(
997 &pkg_dir,
998 ®istered,
999 ®istered_paths,
1000 target_filter,
1001 &mut buckets,
1002 )
1003 .await?;
1004 if target_filter.is_none() {
1005 run_stale_cache_pass(&app_dir.hub_cache_dir(), &mut buckets)?;
1006 if let Some(ref root) = resolved_root {
1007 run_hub_index_pass(root, &mut buckets)?;
1008 }
1009 }
1010
1011 if let Some(target) = target_filter {
1012 if !buckets.any_matched() {
1013 return Err(format!(
1014 "Package '{target}' not found in installed.json, ~/.algocline/packages/, alc.toml, or alc.local.toml"
1015 ));
1016 }
1017 }
1018
1019 Ok(buckets.into_json())
1020 }
1021}
1022
1023#[cfg(test)]
1024mod tests {
1025 use super::*;
1026 use std::path::PathBuf;
1027
1028 use crate::service::test_support::make_app_service_at;
1029
1030 fn mk_entry(source: &str) -> ManifestEntry {
1034 ManifestEntry {
1035 version: None,
1036 source: PackageSource::Path {
1037 path: source.to_string(),
1038 },
1039 installed_at: "2026-01-01T00:00:00Z".to_string(),
1040 updated_at: "2026-01-01T00:00:00Z".to_string(),
1041 pkg_type: None,
1042 }
1043 }
1044
1045 #[test]
1046 fn classify_installed_healthy_dir() {
1047 let tmp = tempfile::tempdir().unwrap();
1048 let pkg_dir = tmp.path();
1049 let dest = pkg_dir.join("p");
1050 std::fs::create_dir(&dest).unwrap();
1051 std::fs::write(
1055 dest.join("init.lua"),
1056 "local M = {} M.meta = { name = \"p\", version = \"0.1.0\", type = \"library\" } return M",
1057 )
1058 .unwrap();
1059
1060 let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir).expect("classify ok");
1061 assert!(matches!(outcome, DoctorOutcome::Healthy));
1062 }
1063
1064 #[test]
1065 fn classify_installed_missing_dir() {
1066 let tmp = tempfile::tempdir().unwrap();
1067 let pkg_dir = tmp.path();
1068
1069 let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir).expect("classify ok");
1070 match outcome {
1071 DoctorOutcome::InstalledMissing { reason, suggestion } => {
1072 assert!(
1073 reason.contains("installed directory missing"),
1074 "reason = {reason}"
1075 );
1076 assert!(
1077 suggestion.contains("alc_pkg_install"),
1078 "suggestion = {suggestion}"
1079 );
1080 assert!(
1081 suggestion.contains("/src/p"),
1082 "suggestion carries source: {suggestion}"
1083 );
1084 }
1085 _ => panic!("expected InstalledMissing"),
1086 }
1087 }
1088
1089 #[test]
1090 #[cfg(unix)]
1091 fn classify_installed_symlink_dangling() {
1092 use std::os::unix::fs::symlink;
1093
1094 let tmp = tempfile::tempdir().unwrap();
1095 let pkg_dir = tmp.path();
1096 let dangling_target = PathBuf::from("/nonexistent/path/for/doctor_test");
1097 symlink(&dangling_target, pkg_dir.join("p")).unwrap();
1098
1099 let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir).expect("classify ok");
1100 match outcome {
1101 DoctorOutcome::SymlinkDangling { reason, suggestion } => {
1102 assert!(reason.contains("symlink target missing"), "{reason}");
1103 assert!(suggestion.contains("alc_pkg_unlink"), "{suggestion}");
1104 }
1105 _ => panic!("expected SymlinkDangling"),
1106 }
1107 }
1108
1109 #[test]
1110 #[cfg(unix)]
1111 fn classify_installed_symlink_alive() {
1112 use std::os::unix::fs::symlink;
1113
1114 let tmp = tempfile::tempdir().unwrap();
1115 let real_target = tmp.path().join("real_target_dir");
1116 std::fs::create_dir(&real_target).unwrap();
1117 std::fs::write(
1120 real_target.join("init.lua"),
1121 "local M = {} M.meta = { name = \"q\", version = \"0.1.0\", type = \"library\" } return M",
1122 )
1123 .unwrap();
1124
1125 let pkg_dir = tmp.path().join("pkgs");
1126 std::fs::create_dir(&pkg_dir).unwrap();
1127 symlink(&real_target, pkg_dir.join("q")).unwrap();
1128
1129 let outcome = classify_installed("q", &mk_entry("/src/q"), &pkg_dir).expect("classify ok");
1130 assert!(matches!(outcome, DoctorOutcome::Healthy));
1131 }
1132
1133 #[test]
1134 fn buckets_into_json_emits_all_eleven_keys() {
1135 let mut b = DoctorBuckets::default();
1145 b.healthy.push(serde_json::json!({"name": "h"}));
1146 b.installed_missing
1147 .push(serde_json::json!({"name": "i", "kind": "installed_missing"}));
1148 b.symlink_dangling
1149 .push(serde_json::json!({"name": "s", "kind": "symlink_dangling"}));
1150 b.path_missing
1151 .push(serde_json::json!({"name": "p", "kind": "path_missing"}));
1152 b.incomplete_pkg
1153 .push(serde_json::json!({"name": "c", "kind": "incomplete_pkg"}));
1154 b.missing_meta
1155 .push(serde_json::json!({"name": "m", "kind": "missing_meta"}));
1156 b.missing_hub_index
1157 .push(serde_json::json!({"kind": "missing_hub_index", "project_root": "/r"}));
1158 b.spec_missing
1159 .push(serde_json::json!({"name": "sm", "kind": "spec_missing"}));
1160 b.stale_cache
1161 .push(serde_json::json!({"kind": "stale_cache", "path": "/p", "age_secs": 7200}));
1162 b.unregistered_pkg.push(serde_json::json!({
1163 "name": "u",
1164 "kind": "unregistered_pkg",
1165 "source": "unknown",
1166 "reason": "physical dir with init.lua exists but is not registered",
1167 "suggestion": ["install", "link", "rm -rf", "note: unknown source"],
1168 }));
1169 b.unmarked_library.push(serde_json::json!({
1170 "name": "ul",
1171 "kind": "unmarked_library",
1172 "suggestion": UNMARKED_LIBRARY_SUGGESTION,
1173 }));
1174
1175 let out = b.into_json();
1176 let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
1177 let obj = parsed.as_object().expect("JSON object");
1178 assert!(obj.contains_key("healthy"));
1179 assert!(obj.contains_key("installed_missing"));
1180 assert!(obj.contains_key("symlink_dangling"));
1181 assert!(obj.contains_key("path_missing"));
1182 assert!(obj.contains_key("incomplete_pkg"));
1183 assert!(obj.contains_key("missing_meta"));
1184 assert!(obj.contains_key("missing_hub_index"));
1185 assert!(obj.contains_key("spec_missing"));
1186 assert!(obj.contains_key("stale_cache"));
1187 assert!(obj.contains_key("unregistered_pkg"));
1188 assert!(obj.contains_key("unmarked_library"));
1189 assert_eq!(obj.len(), 11, "exactly eleven top-level buckets: {out}");
1190
1191 assert_eq!(obj["healthy"][0]["name"], "h");
1192 assert_eq!(obj["installed_missing"][0]["name"], "i");
1193 assert_eq!(obj["symlink_dangling"][0]["name"], "s");
1194 assert_eq!(obj["path_missing"][0]["name"], "p");
1195 assert_eq!(obj["incomplete_pkg"][0]["name"], "c");
1196 assert_eq!(obj["missing_meta"][0]["name"], "m");
1197 assert_eq!(obj["missing_hub_index"][0]["project_root"], "/r");
1198 assert_eq!(obj["spec_missing"][0]["name"], "sm");
1199 assert_eq!(obj["stale_cache"][0]["path"], "/p");
1200 assert_eq!(obj["unregistered_pkg"][0]["name"], "u");
1201 assert_eq!(obj["unregistered_pkg"][0]["kind"], "unregistered_pkg");
1202 assert!(
1204 obj["unregistered_pkg"][0]["suggestion"].is_array(),
1205 "unregistered_pkg suggestion must be an array"
1206 );
1207 assert_eq!(obj["unmarked_library"][0]["name"], "ul");
1208 assert_eq!(obj["unmarked_library"][0]["kind"], "unmarked_library");
1209 assert!(
1211 obj["unmarked_library"][0]["suggestion"].is_string(),
1212 "unmarked_library suggestion must be a string"
1213 );
1214 }
1215
1216 #[test]
1217 fn any_matched_tracks_all_buckets() {
1218 let mut b = DoctorBuckets::default();
1219 assert!(!b.any_matched());
1220 b.healthy.push(serde_json::json!({"name": "h"}));
1221 assert!(b.any_matched());
1222
1223 let mut b = DoctorBuckets::default();
1224 b.installed_missing.push(serde_json::json!({}));
1225 assert!(b.any_matched());
1226
1227 let mut b = DoctorBuckets::default();
1228 b.symlink_dangling.push(serde_json::json!({}));
1229 assert!(b.any_matched());
1230
1231 let mut b = DoctorBuckets::default();
1232 b.path_missing.push(serde_json::json!({}));
1233 assert!(b.any_matched());
1234
1235 let mut b = DoctorBuckets::default();
1236 b.incomplete_pkg.push(serde_json::json!({}));
1237 assert!(b.any_matched());
1238
1239 let mut b = DoctorBuckets::default();
1240 b.missing_meta.push(serde_json::json!({}));
1241 assert!(b.any_matched());
1242
1243 let mut b = DoctorBuckets::default();
1244 b.missing_hub_index.push(serde_json::json!({}));
1245 assert!(b.any_matched());
1246
1247 let mut b = DoctorBuckets::default();
1248 b.spec_missing.push(serde_json::json!({}));
1249 assert!(b.any_matched());
1250
1251 let mut b = DoctorBuckets::default();
1252 b.stale_cache.push(serde_json::json!({}));
1253 assert!(b.any_matched());
1254
1255 let mut b = DoctorBuckets::default();
1256 b.unmarked_library.push(serde_json::json!({}));
1257 assert!(b.any_matched());
1258 }
1259
1260 #[test]
1264 fn check_spec_missing_returns_none_when_spec_file_present() {
1265 let tmp = tempfile::tempdir().unwrap();
1266 let dest = tmp.path().join("mypkg");
1267 std::fs::create_dir_all(dest.join("spec")).unwrap();
1268 std::fs::write(dest.join("spec/foo_spec.lua"), "return {}").unwrap();
1269 let out = check_spec_missing("mypkg", &dest).expect("must not error");
1270 assert!(out.is_none(), "expected None, got: {out:?}");
1271 }
1272
1273 #[test]
1275 fn check_spec_missing_detects_empty_spec_dir() {
1276 let tmp = tempfile::tempdir().unwrap();
1277 let dest = tmp.path().join("mypkg");
1278 std::fs::create_dir_all(dest.join("spec")).unwrap();
1279 let out = check_spec_missing("mypkg", &dest)
1280 .expect("must not error")
1281 .expect("expected SpecMissing");
1282 match out {
1283 DoctorOutcome::SpecMissing { reason, suggestion } => {
1284 assert!(reason.contains("spec"), "reason: {reason}");
1285 assert!(suggestion.contains("_spec.lua"), "suggestion: {suggestion}");
1286 }
1287 _ => panic!("expected SpecMissing, got {out:?}"),
1288 }
1289 }
1290
1291 #[test]
1293 fn check_spec_missing_detects_spec_dir_with_only_non_spec_files() {
1294 let tmp = tempfile::tempdir().unwrap();
1295 let dest = tmp.path().join("mypkg");
1296 std::fs::create_dir_all(dest.join("spec")).unwrap();
1297 std::fs::write(dest.join("spec/helper.lua"), "return {}").unwrap();
1298 std::fs::write(dest.join("spec/README.md"), "docs").unwrap();
1299 let out = check_spec_missing("mypkg", &dest)
1300 .expect("must not error")
1301 .expect("expected SpecMissing");
1302 assert!(matches!(out, DoctorOutcome::SpecMissing { .. }));
1303 }
1304
1305 #[test]
1307 fn check_spec_missing_silently_skips_when_spec_dir_absent() {
1308 let tmp = tempfile::tempdir().unwrap();
1309 let dest = tmp.path().join("mypkg");
1310 std::fs::create_dir_all(&dest).unwrap();
1311 let out = check_spec_missing("mypkg", &dest).expect("must not error");
1312 assert!(
1313 out.is_none(),
1314 "expected None for absent spec/, got: {out:?}"
1315 );
1316 }
1317
1318 #[test]
1321 fn run_stale_cache_pass_emits_when_file_older_than_ttl() {
1322 let tmp = tempfile::tempdir().unwrap();
1323 let cache_dir = tmp.path();
1324 let stale_file = cache_dir.join("abc123.json");
1325 std::fs::write(&stale_file, "{}").unwrap();
1326 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(7200);
1327 let times = std::fs::FileTimes::new().set_modified(past);
1328 let f = std::fs::OpenOptions::new()
1329 .write(true)
1330 .open(&stale_file)
1331 .unwrap();
1332 f.set_times(times).unwrap();
1333
1334 let mut buckets = DoctorBuckets::default();
1335 run_stale_cache_pass(cache_dir, &mut buckets).expect("must not error");
1336 assert_eq!(
1337 buckets.stale_cache.len(),
1338 1,
1339 "expected 1 stale entry: {:?}",
1340 buckets.stale_cache
1341 );
1342 let entry = &buckets.stale_cache[0];
1343 assert_eq!(entry["kind"], "stale_cache");
1344 assert!(entry["path"]
1345 .as_str()
1346 .unwrap_or("")
1347 .ends_with("abc123.json"));
1348 assert!(entry["age_secs"].as_u64().unwrap_or(0) >= 7200);
1349 }
1350
1351 #[test]
1352 fn run_stale_cache_pass_no_emit_for_fresh_file() {
1353 let tmp = tempfile::tempdir().unwrap();
1354 let cache_dir = tmp.path();
1355 let fresh_file = cache_dir.join("xyz789.json");
1356 std::fs::write(&fresh_file, "{}").unwrap();
1357 let mut buckets = DoctorBuckets::default();
1358 run_stale_cache_pass(cache_dir, &mut buckets).expect("must not error");
1359 assert!(
1360 buckets.stale_cache.is_empty(),
1361 "expected no stale entries for fresh file"
1362 );
1363 }
1364
1365 #[test]
1366 fn run_stale_cache_pass_skips_when_cache_dir_absent() {
1367 let tmp = tempfile::tempdir().unwrap();
1368 let missing_dir = tmp.path().join("nonexistent_cache");
1369 let mut buckets = DoctorBuckets::default();
1370 run_stale_cache_pass(&missing_dir, &mut buckets).expect("absent dir must skip with Ok");
1371 assert!(buckets.stale_cache.is_empty());
1372 }
1373
1374 #[test]
1375 fn run_stale_cache_pass_ignores_non_json_files() {
1376 let tmp = tempfile::tempdir().unwrap();
1377 let cache_dir = tmp.path();
1378 let garbage = cache_dir.join(".DS_Store");
1379 std::fs::write(&garbage, "garbage").unwrap();
1380 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(7200);
1381 let times = std::fs::FileTimes::new().set_modified(past);
1382 let f = std::fs::OpenOptions::new()
1383 .write(true)
1384 .open(&garbage)
1385 .unwrap();
1386 f.set_times(times).unwrap();
1387
1388 let mut buckets = DoctorBuckets::default();
1389 run_stale_cache_pass(cache_dir, &mut buckets).expect("must not error");
1390 assert!(
1391 buckets.stale_cache.is_empty(),
1392 "non-json files must be ignored"
1393 );
1394 }
1395
1396 #[test]
1401 fn classify_installed_missing_meta_when_init_lua_lacks_meta() {
1402 let tmp = tempfile::tempdir().unwrap();
1403 let pkg_dir = tmp.path();
1404 let dest = pkg_dir.join("mypkg");
1405 std::fs::create_dir(&dest).unwrap();
1406 std::fs::write(dest.join("init.lua"), "local M = {} return M").unwrap();
1408
1409 let outcome =
1410 classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
1411 match outcome {
1412 DoctorOutcome::MissingMeta { reason, suggestion } => {
1413 assert!(reason.contains("lacks M.meta.name"), "reason: {reason}");
1414 assert!(
1415 suggestion.contains("alc_pkg_install"),
1416 "suggestion: {suggestion}"
1417 );
1418 assert!(
1419 suggestion.contains("mypkg"),
1420 "suggestion carries name: {suggestion}"
1421 );
1422 }
1423 _ => panic!("expected MissingMeta, got {outcome:?}"),
1424 }
1425 }
1426
1427 #[test]
1431 fn classify_installed_missing_meta_when_init_lua_has_empty_meta_name() {
1432 let tmp = tempfile::tempdir().unwrap();
1433 let pkg_dir = tmp.path();
1434 let dest = pkg_dir.join("mypkg");
1435 std::fs::create_dir(&dest).unwrap();
1436 std::fs::write(
1438 dest.join("init.lua"),
1439 "local M = {} M.meta = { name = \"\", version = \"0.1.0\" } return M",
1440 )
1441 .unwrap();
1442
1443 let outcome =
1444 classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
1445 assert!(
1446 matches!(outcome, DoctorOutcome::MissingMeta { .. }),
1447 "expected MissingMeta for empty name, got {outcome:?}"
1448 );
1449 }
1450
1451 #[test]
1456 fn classify_installed_no_missing_meta_when_init_lua_complete() {
1457 let tmp = tempfile::tempdir().unwrap();
1458 let pkg_dir = tmp.path();
1459 let dest = pkg_dir.join("mypkg");
1460 std::fs::create_dir(&dest).unwrap();
1461 std::fs::write(
1462 dest.join("init.lua"),
1463 "local M = {} M.meta = { name = \"mypkg\", version = \"0.1.0\", type = \"library\" } return M",
1464 )
1465 .unwrap();
1466
1467 let outcome =
1468 classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
1469 assert!(
1470 matches!(outcome, DoctorOutcome::Healthy),
1471 "expected Healthy for complete init.lua with explicit type, got {outcome:?}"
1472 );
1473 }
1474
1475 #[test]
1480 fn run_hub_index_pass_emits_when_2_plus_pkgs_and_index_absent() {
1481 let tmp = tempfile::tempdir().unwrap();
1482 let root = tmp.path();
1483 for name in &["pkg_a", "pkg_b"] {
1485 let dir = root.join(name);
1486 std::fs::create_dir(&dir).unwrap();
1487 std::fs::write(dir.join("init.lua"), "return {}").unwrap();
1488 }
1489 let mut buckets = DoctorBuckets::default();
1492 run_hub_index_pass(root, &mut buckets).expect("run_hub_index_pass must not error");
1493
1494 assert_eq!(
1495 buckets.missing_hub_index.len(),
1496 1,
1497 "expected 1 missing_hub_index entry: {:?}",
1498 buckets.missing_hub_index
1499 );
1500 let entry = &buckets.missing_hub_index[0];
1501 assert_eq!(entry["kind"], "missing_hub_index");
1502 assert_eq!(entry["pkg_count"], 2);
1503 assert!(
1504 entry["suggestion"]
1505 .as_str()
1506 .unwrap_or("")
1507 .contains("alc_hub_reindex"),
1508 "suggestion: {entry}"
1509 );
1510 }
1511
1512 #[test]
1515 fn run_hub_index_pass_skips_when_only_1_pkg_dir() {
1516 let tmp = tempfile::tempdir().unwrap();
1517 let root = tmp.path();
1518 let dir = root.join("pkg_a");
1519 std::fs::create_dir(&dir).unwrap();
1520 std::fs::write(dir.join("init.lua"), "return {}").unwrap();
1521 let mut buckets = DoctorBuckets::default();
1524 run_hub_index_pass(root, &mut buckets).expect("run_hub_index_pass must not error");
1525
1526 assert!(
1527 buckets.missing_hub_index.is_empty(),
1528 "must not emit with only 1 pkg dir: {:?}",
1529 buckets.missing_hub_index
1530 );
1531 }
1532
1533 #[test]
1536 fn run_hub_index_pass_skips_when_hub_index_exists() {
1537 let tmp = tempfile::tempdir().unwrap();
1538 let root = tmp.path();
1539 for name in &["pkg_a", "pkg_b"] {
1540 let dir = root.join(name);
1541 std::fs::create_dir(&dir).unwrap();
1542 std::fs::write(dir.join("init.lua"), "return {}").unwrap();
1543 }
1544 std::fs::write(root.join("hub_index.json"), "{}").unwrap();
1546
1547 let mut buckets = DoctorBuckets::default();
1548 run_hub_index_pass(root, &mut buckets).expect("run_hub_index_pass must not error");
1549
1550 assert!(
1551 buckets.missing_hub_index.is_empty(),
1552 "must not emit when hub_index.json exists: {:?}",
1553 buckets.missing_hub_index
1554 );
1555 }
1556
1557 #[test]
1558 fn installed_missing_suggestion_shape() {
1559 let git = PackageSource::Git {
1560 url: "github.com/foo/bar".to_string(),
1561 rev: None,
1562 };
1563 let s = installed_missing_suggestion("ucb", &git);
1564 assert!(s.contains("alc_pkg_install"), "{s}");
1565 assert!(s.contains("\"ucb\""), "{s}");
1566 assert!(s.contains("github.com/foo/bar"), "{s}");
1567 }
1568
1569 #[test]
1574 fn installed_missing_suggestion_routes_bundled_to_alc_init() {
1575 let bundled = PackageSource::Bundled { collection: None };
1576 let s = installed_missing_suggestion("ucb", &bundled);
1577 assert!(s.contains("alc_init"), "bundled must suggest alc_init: {s}");
1578 assert!(
1579 !s.contains("alc_pkg_install"),
1580 "bundled must NOT suggest alc_pkg_install: {s}"
1581 );
1582 }
1583
1584 #[test]
1591 fn installed_missing_suggestion_routes_absolute_path_to_pkg_install() {
1592 let local = PackageSource::Path {
1593 path: "/abs/path/to/src".to_string(),
1594 };
1595 let s = installed_missing_suggestion("local_pkg", &local);
1596 assert!(s.contains("alc_pkg_install"), "{s}");
1597 assert!(s.contains("/abs/path/to/src"), "{s}");
1598 }
1599
1600 #[test]
1604 fn installed_missing_suggestion_routes_unknown_to_reindex() {
1605 let s = installed_missing_suggestion("legacy_pkg", &PackageSource::Unknown);
1606 assert!(
1607 s.contains("alc_hub_reindex"),
1608 "Unknown must suggest alc_hub_reindex: {s}"
1609 );
1610 }
1611
1612 #[test]
1615 fn extract_subs_double_quote() {
1616 let src = r#"
1617local M = {}
1618local check = require("mypkg.check")
1619local t = require("mypkg.t")
1620return M
1621"#;
1622 let subs = extract_required_subs(src, "mypkg");
1623 assert_eq!(subs, vec!["check", "t"]);
1624 }
1625
1626 #[test]
1627 fn extract_subs_single_quote() {
1628 let src = "local x = require('mypkg.sub')";
1629 let subs = extract_required_subs(src, "mypkg");
1630 assert_eq!(subs, vec!["sub"]);
1631 }
1632
1633 #[test]
1634 fn extract_subs_ignores_other_packages() {
1635 let src = r#"
1636local x = require("other.sub")
1637local y = require("mypkg.mine")
1638"#;
1639 let subs = extract_required_subs(src, "mypkg");
1640 assert_eq!(subs, vec!["mine"]);
1641 }
1642
1643 #[test]
1644 fn extract_subs_deduplicates() {
1645 let src = r#"
1646local a = require("mypkg.check")
1647local b = require("mypkg.check")
1648"#;
1649 let subs = extract_required_subs(src, "mypkg");
1650 assert_eq!(subs, vec!["check"]);
1651 }
1652
1653 #[test]
1654 fn extract_subs_ignores_dynamic_require() {
1655 let src = r#"local x = require(mod_name)"#;
1657 let subs = extract_required_subs(src, "mypkg");
1658 assert!(subs.is_empty(), "dynamic require must be ignored: {subs:?}");
1659 }
1660
1661 #[test]
1662 fn extract_subs_ignores_nested_dots() {
1663 let src = r#"local x = require("mypkg.sub.deeper")"#;
1665 let subs = extract_required_subs(src, "mypkg");
1666 assert!(
1667 subs.is_empty(),
1668 "nested dotted require must be ignored: {subs:?}"
1669 );
1670 }
1671
1672 #[test]
1673 fn extract_subs_empty_for_no_require() {
1674 let src = r#"local M = {} return M"#;
1675 let subs = extract_required_subs(src, "mypkg");
1676 assert!(subs.is_empty());
1677 }
1678
1679 #[test]
1682 fn check_incomplete_returns_none_when_all_subs_present_as_lua() {
1683 let tmp = tempfile::tempdir().unwrap();
1684 let dest = tmp.path().join("mypkg");
1685 std::fs::create_dir(&dest).unwrap();
1686 std::fs::write(
1687 dest.join("init.lua"),
1688 r#"local c = require("mypkg.check") return {}"#,
1689 )
1690 .unwrap();
1691 std::fs::write(dest.join("check.lua"), "return {}").unwrap();
1692
1693 assert!(check_incomplete("mypkg", &dest, false).is_none());
1694 }
1695
1696 #[test]
1697 fn check_incomplete_returns_none_when_sub_is_dir_init() {
1698 let tmp = tempfile::tempdir().unwrap();
1699 let dest = tmp.path().join("mypkg");
1700 std::fs::create_dir(&dest).unwrap();
1701 std::fs::write(
1702 dest.join("init.lua"),
1703 r#"local c = require("mypkg.sub") return {}"#,
1704 )
1705 .unwrap();
1706 std::fs::create_dir(dest.join("sub")).unwrap();
1708 std::fs::write(dest.join("sub").join("init.lua"), "return {}").unwrap();
1709
1710 assert!(check_incomplete("mypkg", &dest, false).is_none());
1711 }
1712
1713 #[test]
1714 fn check_incomplete_detects_missing_sub() {
1715 let tmp = tempfile::tempdir().unwrap();
1716 let dest = tmp.path().join("mypkg");
1717 std::fs::create_dir(&dest).unwrap();
1718 std::fs::write(
1719 dest.join("init.lua"),
1720 r#"
1721local check = require("mypkg.check")
1722local t = require("mypkg.t")
1723return {}
1724"#,
1725 )
1726 .unwrap();
1727 std::fs::write(dest.join("check.lua"), "return {}").unwrap();
1729
1730 let outcome = check_incomplete("mypkg", &dest, false).expect("should detect incomplete");
1731 match outcome {
1732 DoctorOutcome::IncompletePkg {
1733 missing_subs,
1734 suggestion,
1735 } => {
1736 assert_eq!(missing_subs, vec!["t"], "missing_subs: {missing_subs:?}");
1737 assert!(
1738 suggestion.contains("alc_pkg_install"),
1739 "non-symlink suggestion: {suggestion}"
1740 );
1741 }
1742 _ => panic!("expected IncompletePkg"),
1743 }
1744 }
1745
1746 #[test]
1747 fn check_incomplete_suggestion_uses_link_for_symlink() {
1748 let tmp = tempfile::tempdir().unwrap();
1749 let dest = tmp.path().join("mypkg");
1750 std::fs::create_dir(&dest).unwrap();
1751 std::fs::write(
1752 dest.join("init.lua"),
1753 r#"local x = require("mypkg.missing") return {}"#,
1754 )
1755 .unwrap();
1756 let outcome = check_incomplete("mypkg", &dest, true).expect("should detect incomplete");
1759 match outcome {
1760 DoctorOutcome::IncompletePkg { suggestion, .. } => {
1761 assert!(
1762 suggestion.contains("alc_pkg_link"),
1763 "symlink suggestion: {suggestion}"
1764 );
1765 }
1766 _ => panic!("expected IncompletePkg"),
1767 }
1768 }
1769
1770 #[test]
1771 fn check_incomplete_returns_none_when_no_init_lua() {
1772 let tmp = tempfile::tempdir().unwrap();
1774 let dest = tmp.path().join("mypkg");
1775 std::fs::create_dir(&dest).unwrap();
1776
1777 assert!(check_incomplete("mypkg", &dest, false).is_none());
1778 }
1779
1780 #[test]
1781 fn classify_installed_incomplete_pkg() {
1782 let tmp = tempfile::tempdir().unwrap();
1784 let pkg_dir = tmp.path();
1785 let dest = pkg_dir.join("mypkg");
1786 std::fs::create_dir(&dest).unwrap();
1787 std::fs::write(
1788 dest.join("init.lua"),
1789 r#"local x = require("mypkg.sub") return {}"#,
1790 )
1791 .unwrap();
1792 let outcome =
1795 classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
1796 match outcome {
1797 DoctorOutcome::IncompletePkg {
1798 missing_subs,
1799 suggestion,
1800 } => {
1801 assert_eq!(missing_subs, vec!["sub"]);
1802 assert!(suggestion.contains("alc_pkg_install"), "{suggestion}");
1803 }
1804 _ => panic!("expected IncompletePkg, got {outcome:?}"),
1805 }
1806 }
1807
1808 #[test]
1809 fn classify_installed_healthy_when_all_subs_present() {
1810 let tmp = tempfile::tempdir().unwrap();
1815 let pkg_dir = tmp.path();
1816 let dest = pkg_dir.join("mypkg");
1817 std::fs::create_dir(&dest).unwrap();
1818 std::fs::write(
1819 dest.join("init.lua"),
1820 "local M = {}\n\
1821 M.meta = { name = \"mypkg\", version = \"0.1.0\", type = \"library\" }\n\
1822 local x = require(\"mypkg.sub\")\n\
1823 return M",
1824 )
1825 .unwrap();
1826 std::fs::write(dest.join("sub.lua"), "return {}").unwrap();
1827
1828 let outcome =
1829 classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
1830 assert!(
1831 matches!(outcome, DoctorOutcome::Healthy),
1832 "expected Healthy, got {outcome:?}"
1833 );
1834 }
1835
1836 #[test]
1844 fn classify_installed_detects_auto_library() {
1845 let tmp = tempfile::tempdir().unwrap();
1846 let pkg_dir = tmp.path();
1847 let dest = pkg_dir.join("mylib");
1848 std::fs::create_dir(&dest).unwrap();
1849 std::fs::write(
1852 dest.join("init.lua"),
1853 "local M = {}\n\
1854 M.meta = { name = \"mylib\", version = \"0.1.0\" }\n\
1855 return M",
1856 )
1857 .unwrap();
1858
1859 let outcome =
1860 classify_installed("mylib", &mk_entry("/src/mylib"), pkg_dir).expect("classify ok");
1861 match outcome {
1862 DoctorOutcome::UnmarkedLibrary { suggestion } => {
1863 assert!(
1864 suggestion.contains("M.meta.type"),
1865 "suggestion must mention M.meta.type: {suggestion}"
1866 );
1867 assert_eq!(
1868 suggestion, UNMARKED_LIBRARY_SUGGESTION,
1869 "suggestion must match the canonical UNMARKED_LIBRARY_SUGGESTION const"
1870 );
1871 }
1872 _ => panic!("expected UnmarkedLibrary, got {outcome:?}"),
1873 }
1874 }
1875
1876 #[test]
1880 fn classify_installed_explicit_library_does_not_warn() {
1881 let tmp = tempfile::tempdir().unwrap();
1882 let pkg_dir = tmp.path();
1883 let dest = pkg_dir.join("explicitlib");
1884 std::fs::create_dir(&dest).unwrap();
1885 std::fs::write(
1888 dest.join("init.lua"),
1889 "local M = {}\n\
1890 M.meta = { name = \"explicitlib\", version = \"0.1.0\", type = \"library\" }\n\
1891 return M",
1892 )
1893 .unwrap();
1894
1895 let outcome = classify_installed("explicitlib", &mk_entry("/src/explicitlib"), pkg_dir)
1896 .expect("classify ok");
1897 assert!(
1898 matches!(outcome, DoctorOutcome::Healthy),
1899 "explicit library must be Healthy, got {outcome:?}"
1900 );
1901 }
1902
1903 #[test]
1907 fn classify_installed_runnable_does_not_warn() {
1908 let tmp = tempfile::tempdir().unwrap();
1909 let pkg_dir = tmp.path();
1910 let dest = pkg_dir.join("myrunnable");
1911 std::fs::create_dir(&dest).unwrap();
1912 std::fs::write(
1914 dest.join("init.lua"),
1915 "local M = {}\n\
1916 M.meta = { name = \"myrunnable\", version = \"0.1.0\" }\n\
1917 function M.run(ctx) return {} end\n\
1918 return M",
1919 )
1920 .unwrap();
1921
1922 let outcome = classify_installed("myrunnable", &mk_entry("/src/myrunnable"), pkg_dir)
1923 .expect("classify ok");
1924 assert!(
1925 matches!(outcome, DoctorOutcome::Healthy),
1926 "runnable pkg must be Healthy (not UnmarkedLibrary), got {outcome:?}"
1927 );
1928 }
1929
1930 #[test]
1934 fn classify_installed_none_type_source_does_not_trigger_warn() {
1935 let tmp = tempfile::tempdir().unwrap();
1936 let pkg_dir = tmp.path();
1937 let dest = pkg_dir.join("legacypkg");
1938 std::fs::create_dir(&dest).unwrap();
1939 std::fs::write(dest.join("init.lua"), "local M = {} return M").unwrap();
1941
1942 let outcome = classify_installed("legacypkg", &mk_entry("/src/legacypkg"), pkg_dir)
1943 .expect("classify ok");
1944 assert!(
1946 matches!(outcome, DoctorOutcome::MissingMeta { .. }),
1947 "None type_source must not trigger UnmarkedLibrary; expected MissingMeta, got {outcome:?}"
1948 );
1949 }
1950
1951 #[cfg(unix)]
1957 #[tokio::test]
1958 async fn run_alive_unregistered_symlink_pass_unmarked_library() {
1959 let tmp = tempfile::tempdir().unwrap();
1960 let root = tmp.path().to_path_buf();
1961
1962 let pkg_dir = root.join("packages");
1964 std::fs::create_dir_all(&pkg_dir).unwrap();
1965
1966 let real = root.join("real_lib");
1968 std::fs::create_dir(&real).unwrap();
1969 std::fs::write(
1971 real.join("init.lua"),
1972 "local M = {}\nM.meta = { name = \"mylib\", version = \"0.1.0\" }\nreturn M",
1973 )
1974 .unwrap();
1975
1976 let link = pkg_dir.join("mylib");
1978 std::os::unix::fs::symlink(&real, &link).unwrap();
1979
1980 let app_service = make_app_service_at(root).await;
1981 let registered = HashSet::new();
1982 let registered_paths: Vec<PathBuf> = vec![];
1983 let mut buckets = DoctorBuckets::default();
1984 app_service
1985 .run_alive_unregistered_symlink_pass(
1986 &pkg_dir,
1987 ®istered,
1988 ®istered_paths,
1989 None,
1990 &mut buckets,
1991 )
1992 .await
1993 .expect("pass ok");
1994
1995 assert_eq!(
1996 buckets.unmarked_library.len(),
1997 1,
1998 "expected 1 unmarked_library entry, got {:?}",
1999 buckets.unmarked_library
2000 );
2001 assert_eq!(buckets.unregistered_pkg.len(), 0);
2002 let entry = &buckets.unmarked_library[0];
2003 assert_eq!(entry["name"], "mylib");
2004 assert_eq!(entry["kind"], "unmarked_library");
2005 assert!(
2006 entry["suggestion"]
2007 .as_str()
2008 .unwrap_or("")
2009 .contains("M.meta.type"),
2010 "suggestion must mention M.meta.type: {:?}",
2011 entry["suggestion"]
2012 );
2013 }
2014
2015 #[cfg(unix)]
2020 #[tokio::test]
2021 async fn run_alive_unregistered_symlink_pass_unregistered_pkg() {
2022 let tmp = tempfile::tempdir().unwrap();
2023 let root = tmp.path().to_path_buf();
2024
2025 let pkg_dir = root.join("packages");
2027 std::fs::create_dir_all(&pkg_dir).unwrap();
2028
2029 let real = root.join("real_runnable");
2031 std::fs::create_dir(&real).unwrap();
2032 std::fs::write(
2034 real.join("init.lua"),
2035 "local M = {}\n\
2036 M.meta = { name = \"myrunnable\", version = \"0.1.0\" }\n\
2037 function M.run(ctx) return {} end\n\
2038 return M",
2039 )
2040 .unwrap();
2041
2042 let link = pkg_dir.join("myrunnable");
2044 std::os::unix::fs::symlink(&real, &link).unwrap();
2045
2046 let app_service = make_app_service_at(root).await;
2047 let registered = HashSet::new();
2048 let registered_paths: Vec<PathBuf> = vec![];
2049 let mut buckets = DoctorBuckets::default();
2050 app_service
2051 .run_alive_unregistered_symlink_pass(
2052 &pkg_dir,
2053 ®istered,
2054 ®istered_paths,
2055 None,
2056 &mut buckets,
2057 )
2058 .await
2059 .expect("pass ok");
2060
2061 assert_eq!(
2062 buckets.unregistered_pkg.len(),
2063 1,
2064 "expected 1 unregistered_pkg entry, got {:?}",
2065 buckets.unregistered_pkg
2066 );
2067 assert_eq!(buckets.unmarked_library.len(), 0);
2068 let entry = &buckets.unregistered_pkg[0];
2069 assert_eq!(entry["name"], "myrunnable");
2070 assert_eq!(entry["kind"], "unregistered_pkg");
2071 assert_eq!(entry["source"], "unknown");
2072 assert!(
2073 entry["reason"]
2074 .as_str()
2075 .unwrap_or("")
2076 .contains("alive symlink"),
2077 "reason must mention alive symlink: {:?}",
2078 entry["reason"]
2079 );
2080 let suggestion = entry["suggestion"].as_array().expect("suggestion is array");
2081 assert_eq!(suggestion.len(), 4, "suggestion must have 4 elements");
2082 }
2083}