1use std::collections::HashSet;
49use std::path::{Path, PathBuf};
50
51use algocline_core::PkgEntity;
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, ProjectPathSource,
62};
63
64const DOCTOR_CACHE_TTL_SECS: u64 = 3600;
68
69#[derive(Debug)]
71enum DoctorOutcome {
72 Healthy,
74 SymlinkDangling { reason: String, suggestion: String },
76 InstalledMissing { reason: String, suggestion: String },
78 IncompletePkg {
81 missing_subs: Vec<String>,
82 suggestion: String,
83 },
84 MissingMeta { reason: String, suggestion: String },
87 SpecMissing { reason: String, suggestion: String },
90}
91
92#[derive(Default)]
94struct DoctorBuckets {
95 healthy: Vec<serde_json::Value>,
96 installed_missing: Vec<serde_json::Value>,
97 symlink_dangling: Vec<serde_json::Value>,
98 path_missing: Vec<serde_json::Value>,
99 incomplete_pkg: Vec<serde_json::Value>,
100 missing_meta: Vec<serde_json::Value>,
101 missing_hub_index: Vec<serde_json::Value>,
102 spec_missing: Vec<serde_json::Value>,
103 stale_cache: Vec<serde_json::Value>,
104 unregistered_pkg: Vec<serde_json::Value>,
111}
112
113impl DoctorBuckets {
114 fn any_matched(&self) -> bool {
115 !self.healthy.is_empty()
116 || !self.installed_missing.is_empty()
117 || !self.symlink_dangling.is_empty()
118 || !self.path_missing.is_empty()
119 || !self.incomplete_pkg.is_empty()
120 || !self.missing_meta.is_empty()
121 || !self.missing_hub_index.is_empty()
122 || !self.spec_missing.is_empty()
123 || !self.stale_cache.is_empty()
124 || !self.unregistered_pkg.is_empty()
125 }
126
127 fn into_json(self) -> String {
128 serde_json::json!({
132 "healthy": self.healthy,
133 "incomplete_pkg": self.incomplete_pkg,
134 "installed_missing": self.installed_missing,
135 "missing_hub_index": self.missing_hub_index,
136 "missing_meta": self.missing_meta,
137 "path_missing": self.path_missing,
138 "spec_missing": self.spec_missing,
139 "stale_cache": self.stale_cache,
140 "symlink_dangling": self.symlink_dangling,
141 "unregistered_pkg": self.unregistered_pkg,
142 })
143 .to_string()
144 }
145}
146
147fn extract_required_subs(lua_src: &str, pkg_name: &str) -> Vec<String> {
161 let mut subs = Vec::new();
162 let prefix = format!("{pkg_name}.");
163 let mut remaining = lua_src;
164
165 while let Some(pos) = remaining.find("require") {
166 remaining = &remaining[pos + "require".len()..];
167
168 let trimmed = remaining.trim_start_matches([' ', '\t']);
170
171 if !trimmed.starts_with('(') {
173 continue;
174 }
175 let after_paren = &trimmed[1..];
176 let after_paren = after_paren.trim_start_matches([' ', '\t']);
177
178 let quote = match after_paren.chars().next() {
180 Some(q @ '"') | Some(q @ '\'') => q,
181 _ => continue,
182 };
183 let content = &after_paren[1..];
184 let end = match content.find(quote) {
185 Some(i) => i,
186 None => continue,
187 };
188 let module = &content[..end];
189
190 if let Some(sub) = module.strip_prefix(&prefix) {
191 if !sub.is_empty() && !sub.contains('.') {
192 subs.push(sub.to_string());
194 }
195 }
196 }
197
198 subs.sort();
199 subs.dedup();
200 subs
201}
202
203fn incomplete_pkg_suggestion(name: &str, is_symlink: bool) -> String {
206 if is_symlink {
207 format!("Re-run alc_pkg_link <path> to re-link {name:?} with the complete source directory")
208 } else {
209 format!(
210 "Run alc_pkg_install --force {name:?} to reinstall {name:?} with all submodule files"
211 )
212 }
213}
214
215fn installed_missing_suggestion(name: &str, entry_source: &PackageSource) -> String {
226 match entry_source {
227 PackageSource::Bundled { .. } => {
228 "alc_init (reinstalls bundled packages from the algocline binary)".to_string()
229 }
230 PackageSource::Path { path } => {
231 format!("alc_pkg_install({path:?}) to reinstall {name:?} from local path")
232 }
233 PackageSource::Git { url, .. } => {
234 format!("alc_pkg_install({url:?}) to reinstall {name:?} from Git")
235 }
236 PackageSource::Installed => {
237 format!(
238 "alc_pkg_install <path-or-url> to re-record source for {name:?} \
239 (legacy 'installed' marker carries no path)"
240 )
241 }
242 PackageSource::Unknown => {
243 format!(
244 "alc_hub_reindex then alc_pkg_install <path-or-url> for {name:?} \
245 (source unknown — legacy entry)"
246 )
247 }
248 }
249}
250
251fn push_doctor_outcome(name: &str, outcome: DoctorOutcome, buckets: &mut DoctorBuckets) {
253 match outcome {
254 DoctorOutcome::Healthy => buckets.healthy.push(serde_json::json!({
255 "name": name,
256 })),
257 DoctorOutcome::SymlinkDangling { reason, suggestion } => {
258 buckets.symlink_dangling.push(serde_json::json!({
259 "name": name,
260 "kind": "symlink_dangling",
261 "reason": reason,
262 "suggestion": suggestion,
263 }))
264 }
265 DoctorOutcome::InstalledMissing { reason, suggestion } => {
266 buckets.installed_missing.push(serde_json::json!({
267 "name": name,
268 "kind": "installed_missing",
269 "reason": reason,
270 "suggestion": suggestion,
271 }))
272 }
273 DoctorOutcome::IncompletePkg {
274 missing_subs,
275 suggestion,
276 } => buckets.incomplete_pkg.push(serde_json::json!({
277 "name": name,
278 "kind": "incomplete_pkg",
279 "missing_subs": missing_subs,
280 "suggestion": suggestion,
281 })),
282 DoctorOutcome::MissingMeta { reason, suggestion } => {
283 buckets.missing_meta.push(serde_json::json!({
284 "name": name,
285 "kind": "missing_meta",
286 "reason": reason,
287 "suggestion": suggestion,
288 }))
289 }
290 DoctorOutcome::SpecMissing { reason, suggestion } => {
291 buckets.spec_missing.push(serde_json::json!({
292 "name": name,
293 "kind": "spec_missing",
294 "reason": reason,
295 "suggestion": suggestion,
296 }))
297 }
298 }
299}
300
301fn check_incomplete(name: &str, dest: &Path, is_symlink: bool) -> Option<DoctorOutcome> {
313 let init_lua = dest.join("init.lua");
314 let src = match std::fs::read_to_string(&init_lua) {
315 Ok(s) => s,
316 Err(e) => {
317 warn!(
318 error = %e,
319 path = %init_lua.display(),
320 "could not read init.lua for incomplete check; skipping"
321 );
322 return None;
323 }
324 };
325
326 let required_subs = extract_required_subs(&src, name);
327 if required_subs.is_empty() {
328 return None;
329 }
330
331 let missing: Vec<String> = required_subs
332 .into_iter()
333 .filter(|sub| {
334 let as_file = dest.join(format!("{sub}.lua"));
335 let as_dir = dest.join(sub).join("init.lua");
336 !as_file.exists() && !as_dir.exists()
337 })
338 .collect();
339
340 if missing.is_empty() {
341 return None;
342 }
343
344 Some(DoctorOutcome::IncompletePkg {
345 missing_subs: missing,
346 suggestion: incomplete_pkg_suggestion(name, is_symlink),
347 })
348}
349
350fn check_missing_meta(name: &str, dest: &Path) -> Option<DoctorOutcome> {
360 let init_lua = dest.join("init.lua");
361 if PkgEntity::parse_from_init_lua(&init_lua).is_some() {
362 return None;
363 }
364 Some(DoctorOutcome::MissingMeta {
365 reason: format!("init.lua at {} lacks M.meta.name", init_lua.display()),
366 suggestion: format!(
367 "Package directory at {} lacks M.meta.name in init.lua — \
368 run alc_pkg_install --force {name:?} or fix init.lua to declare \
369 M.meta = {{ name = ..., version = ... }}",
370 dest.display()
371 ),
372 })
373}
374
375fn check_spec_missing(name: &str, dest: &Path) -> Result<Option<DoctorOutcome>, String> {
385 let spec_dir = dest.join("spec");
386 if !spec_dir.is_dir() {
387 return Ok(None);
388 }
389 let entries = std::fs::read_dir(&spec_dir).map_err(|e| {
390 format!(
391 "spec_missing: failed to read_dir {}: {e}",
392 spec_dir.display()
393 )
394 })?;
395 let mut found_spec = false;
396 for entry in entries {
397 let entry = entry.map_err(|e| format!("spec_missing: failed to read dir entry: {e}"))?;
398 let ft = entry.file_type().map_err(|e| {
399 format!(
400 "spec_missing: failed to read file_type for {}: {e}",
401 entry.path().display()
402 )
403 })?;
404 if !ft.is_file() {
405 continue;
406 }
407 let fname = entry.file_name();
408 if fname.to_string_lossy().ends_with("_spec.lua") {
409 found_spec = true;
410 break;
411 }
412 }
413 if found_spec {
414 return Ok(None);
415 }
416 Ok(Some(DoctorOutcome::SpecMissing {
417 reason: format!(
418 "spec directory at {} exists but contains zero *_spec.lua files",
419 spec_dir.display()
420 ),
421 suggestion: format!(
422 "Package {name:?} declared test intent by creating spec/ at {} — \
423 add at least one <name>_spec.lua file (mlua-lspec convention) or remove \
424 the spec/ directory to opt out of spec discipline",
425 spec_dir.display()
426 ),
427 }))
428}
429
430fn classify_installed(
438 name: &str,
439 entry: &ManifestEntry,
440 pkg_dir: &Path,
441) -> Result<DoctorOutcome, String> {
442 let dest = pkg_dir.join(name);
443
444 let is_symlink = dest
445 .symlink_metadata()
446 .map(|m| m.file_type().is_symlink())
447 .unwrap_or(false);
448 if is_symlink {
449 let target_alive = match dest.try_exists() {
451 Ok(v) => v,
452 Err(e) => {
453 warn!(error = %e, path = %dest.display(), "try_exists failed; treating symlink target as dead");
454 false
455 }
456 };
457 if target_alive {
458 if let Some(incomplete) = check_incomplete(name, &dest, true) {
460 return Ok(incomplete);
461 }
462 if let Some(mm) = check_missing_meta(name, &dest) {
463 return Ok(mm);
464 }
465 if let Some(sm) = check_spec_missing(name, &dest)? {
466 return Ok(sm);
467 }
468 return Ok(DoctorOutcome::Healthy);
469 }
470 let link_target = match dest.read_link() {
471 Ok(t) => t.display().to_string(),
472 Err(e) => {
473 warn!(error = %e, path = %dest.display(), "read_link failed; using placeholder for dangling target");
474 "<unknown>".to_string()
475 }
476 };
477 return Ok(DoctorOutcome::SymlinkDangling {
478 reason: format!("symlink target missing: {link_target}"),
479 suggestion: symlink_dangling_suggestion(name),
480 });
481 }
482
483 if dest.exists() {
484 if let Some(incomplete) = check_incomplete(name, &dest, false) {
486 return Ok(incomplete);
487 }
488 if let Some(mm) = check_missing_meta(name, &dest) {
489 return Ok(mm);
490 }
491 if let Some(sm) = check_spec_missing(name, &dest)? {
492 return Ok(sm);
493 }
494 return Ok(DoctorOutcome::Healthy);
495 }
496
497 Ok(DoctorOutcome::InstalledMissing {
498 reason: format!("installed directory missing: {}", dest.display()),
499 suggestion: installed_missing_suggestion(name, &entry.source),
500 })
501}
502
503fn run_manifest_pass(
507 manifest: &Manifest,
508 target_filter: Option<&str>,
509 pkg_dir: &Path,
510 buckets: &mut DoctorBuckets,
511) -> Result<(), String> {
512 if let Some(target) = target_filter {
513 if let Some(entry) = manifest.packages.get(target) {
514 let outcome = classify_installed(target, entry, pkg_dir)?;
515 push_doctor_outcome(target, outcome, buckets);
516 }
517 return Ok(());
518 }
519 for (pkg_name, entry) in &manifest.packages {
520 let outcome = classify_installed(pkg_name, entry, pkg_dir)?;
521 push_doctor_outcome(pkg_name, outcome, buckets);
522 }
523 Ok(())
524}
525
526fn run_unattached_symlink_pass(
530 pkg_dir: &Path,
531 target_filter: Option<&str>,
532 manifest: &Manifest,
533 buckets: &mut DoctorBuckets,
534) {
535 let mut scratch: Vec<serde_json::Value> = Vec::new();
536 collect_unattached_dangling_symlinks(pkg_dir, target_filter, &manifest.packages, &mut scratch);
537 buckets.symlink_dangling.extend(scratch);
538}
539
540fn run_path_missing_pass(
544 resolved_root: Option<&Path>,
545 target_filter: Option<&str>,
546 buckets: &mut DoctorBuckets,
547) {
548 let Some(root) = resolved_root else {
549 return;
550 };
551 let mut scratch: Vec<serde_json::Value> = Vec::new();
552 collect_path_missing(
553 root,
554 target_filter,
555 "project",
556 &mut scratch,
557 ProjectPathSource::Toml,
558 );
559 collect_path_missing(
560 root,
561 target_filter,
562 "variant",
563 &mut scratch,
564 ProjectPathSource::Local,
565 );
566 buckets.path_missing.extend(scratch);
567}
568
569fn run_hub_index_pass(root: &Path, buckets: &mut DoctorBuckets) -> Result<(), String> {
587 let mut pkg_count = 0usize;
588 let entries = std::fs::read_dir(root).map_err(|e| {
589 format!(
590 "hub_index_pass: failed to read project_root {}: {e}",
591 root.display()
592 )
593 })?;
594 for entry in entries {
595 let entry = entry.map_err(|e| format!("hub_index_pass: failed to read dir entry: {e}"))?;
596 let ft = entry
597 .file_type()
598 .map_err(|e| format!("hub_index_pass: failed to read file_type: {e}"))?;
599 if !ft.is_dir() {
600 continue;
601 }
602 let init_lua = entry.path().join("init.lua");
603 let exists = init_lua.try_exists().map_err(|e| {
604 format!(
605 "hub_index_pass: try_exists failed for {}: {e}",
606 init_lua.display()
607 )
608 })?;
609 if exists {
610 pkg_count += 1;
611 }
612 }
613 if pkg_count < 2 {
614 return Ok(());
615 }
616 let hub_index = root.join("hub_index.json");
617 let has_index = hub_index.try_exists().map_err(|e| {
618 format!(
619 "hub_index_pass: try_exists failed for {}: {e}",
620 hub_index.display()
621 )
622 })?;
623 if has_index {
624 return Ok(());
625 }
626 buckets.missing_hub_index.push(serde_json::json!({
627 "kind": "missing_hub_index",
628 "project_root": root.display().to_string(),
629 "pkg_count": pkg_count,
630 "suggestion": format!(
631 "Collection project root contains {pkg_count} package dirs but \
632 {}/hub_index.json is missing — run alc_hub_reindex --source_dir {} \
633 to generate it",
634 root.display(),
635 root.display()
636 ),
637 }));
638 Ok(())
639}
640
641fn run_stale_cache_pass(cache_dir: &Path, buckets: &mut DoctorBuckets) -> Result<(), String> {
657 let exists = cache_dir.try_exists().map_err(|e| {
658 format!(
659 "stale_cache_pass: try_exists failed for {}: {e}",
660 cache_dir.display()
661 )
662 })?;
663 if !exists {
664 return Ok(());
665 }
666 let entries = std::fs::read_dir(cache_dir).map_err(|e| {
667 format!(
668 "stale_cache_pass: failed to read_dir {}: {e}",
669 cache_dir.display()
670 )
671 })?;
672 for entry in entries {
673 let entry =
674 entry.map_err(|e| format!("stale_cache_pass: failed to read dir entry: {e}"))?;
675 let ft = entry.file_type().map_err(|e| {
676 format!(
677 "stale_cache_pass: failed to read file_type for {}: {e}",
678 entry.path().display()
679 )
680 })?;
681 if !ft.is_file() {
682 continue;
683 }
684 let path = entry.path();
685 if path.extension().and_then(|s| s.to_str()) != Some("json") {
686 continue;
687 }
688 let metadata = entry.metadata().map_err(|e| {
689 format!(
690 "stale_cache_pass: failed to read metadata for {}: {e}",
691 path.display()
692 )
693 })?;
694 let Some(modified) = metadata.modified().ok() else {
697 continue;
698 };
699 let Some(age) = modified.elapsed().ok() else {
700 continue;
701 };
702 if age.as_secs() <= DOCTOR_CACHE_TTL_SECS {
703 continue;
704 }
705 buckets.stale_cache.push(serde_json::json!({
706 "kind": "stale_cache",
707 "path": path.display().to_string(),
708 "age_secs": age.as_secs(),
709 "suggestion": format!(
710 "Run alc_hub_search to refresh stale cache (>{DOCTOR_CACHE_TTL_SECS}s old)"
711 ),
712 }));
713 }
714 Ok(())
715}
716
717fn run_unregistered_pkg_pass(
736 pkg_dir: &Path,
737 registered: &HashSet<String>,
738 registered_paths: &[PathBuf],
739 target_filter: Option<&str>,
740 buckets: &mut DoctorBuckets,
741) -> Result<(), String> {
742 let found =
743 collect_unregistered_pkg_dirs(pkg_dir, registered, registered_paths, target_filter)?;
744 buckets.unregistered_pkg.extend(found);
745 Ok(())
746}
747
748impl AppService {
749 pub async fn pkg_doctor(
773 &self,
774 name: Option<String>,
775 project_root: Option<String>,
776 ) -> Result<String, String> {
777 let app_dir = self.log_config.app_dir();
778 let manifest = load_manifest(&app_dir)?;
779 let pkg_dir = packages_dir(&app_dir);
780 let resolved_root = self.resolve_root(project_root.as_deref());
781 let target_filter = name.as_deref();
782
783 let mut registered: HashSet<String> = manifest.packages.keys().cloned().collect();
788 let mut registered_paths: Vec<PathBuf> = Vec::new();
789
790 if let Some(ref root) = resolved_root {
791 if let Some(toml_data) = load_alc_toml(root)? {
793 for (name, dep) in &toml_data.packages {
794 registered.insert(name.clone());
795 if let PackageDep::Path { path, .. } = dep {
796 let raw = std::path::Path::new(path);
797 let abs = if raw.is_absolute() {
798 raw.to_path_buf()
799 } else {
800 root.join(raw)
801 };
802 match abs.canonicalize() {
803 Ok(c) => registered_paths.push(c),
804 Err(e) => {
805 tracing::warn!(
811 "pkg: cannot canonicalize alc.toml path entry \
812 for '{}' ({}): {e}",
813 name,
814 abs.display()
815 );
816 }
817 }
818 }
819 }
820 }
821 if let Some(local_data) = load_alc_local_toml(root)? {
823 for (name, dep) in &local_data.packages {
824 registered.insert(name.clone());
825 if let PackageDep::Path { path, .. } = dep {
826 let raw = std::path::Path::new(path);
827 let abs = if raw.is_absolute() {
828 raw.to_path_buf()
829 } else {
830 root.join(raw)
831 };
832 match abs.canonicalize() {
833 Ok(c) => registered_paths.push(c),
834 Err(e) => {
835 tracing::warn!(
836 "pkg: cannot canonicalize alc.local.toml path entry \
837 for '{}' ({}): {e}",
838 name,
839 abs.display()
840 );
841 }
842 }
843 }
844 }
845 }
846 }
847
848 let mut buckets = DoctorBuckets::default();
849 run_manifest_pass(&manifest, target_filter, &pkg_dir, &mut buckets)?;
850 run_unattached_symlink_pass(&pkg_dir, target_filter, &manifest, &mut buckets);
851 run_path_missing_pass(resolved_root.as_deref(), target_filter, &mut buckets);
852 run_unregistered_pkg_pass(
853 &pkg_dir,
854 ®istered,
855 ®istered_paths,
856 target_filter,
857 &mut buckets,
858 )?;
859 if target_filter.is_none() {
860 run_stale_cache_pass(&app_dir.hub_cache_dir(), &mut buckets)?;
861 if let Some(ref root) = resolved_root {
862 run_hub_index_pass(root, &mut buckets)?;
863 }
864 }
865
866 if let Some(target) = target_filter {
867 if !buckets.any_matched() {
868 return Err(format!(
869 "Package '{target}' not found in installed.json, ~/.algocline/packages/, alc.toml, or alc.local.toml"
870 ));
871 }
872 }
873
874 Ok(buckets.into_json())
875 }
876}
877
878#[cfg(test)]
879mod tests {
880 use super::*;
881 use std::path::PathBuf;
882
883 fn mk_entry(source: &str) -> ManifestEntry {
887 ManifestEntry {
888 version: None,
889 source: PackageSource::Path {
890 path: source.to_string(),
891 },
892 installed_at: "2026-01-01T00:00:00Z".to_string(),
893 updated_at: "2026-01-01T00:00:00Z".to_string(),
894 }
895 }
896
897 #[test]
898 fn classify_installed_healthy_dir() {
899 let tmp = tempfile::tempdir().unwrap();
900 let pkg_dir = tmp.path();
901 let dest = pkg_dir.join("p");
902 std::fs::create_dir(&dest).unwrap();
903 std::fs::write(
905 dest.join("init.lua"),
906 "local M = {} M.meta = { name = \"p\", version = \"0.1.0\" } return M",
907 )
908 .unwrap();
909
910 let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir).expect("classify ok");
911 assert!(matches!(outcome, DoctorOutcome::Healthy));
912 }
913
914 #[test]
915 fn classify_installed_missing_dir() {
916 let tmp = tempfile::tempdir().unwrap();
917 let pkg_dir = tmp.path();
918
919 let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir).expect("classify ok");
920 match outcome {
921 DoctorOutcome::InstalledMissing { reason, suggestion } => {
922 assert!(
923 reason.contains("installed directory missing"),
924 "reason = {reason}"
925 );
926 assert!(
927 suggestion.contains("alc_pkg_install"),
928 "suggestion = {suggestion}"
929 );
930 assert!(
931 suggestion.contains("/src/p"),
932 "suggestion carries source: {suggestion}"
933 );
934 }
935 _ => panic!("expected InstalledMissing"),
936 }
937 }
938
939 #[test]
940 #[cfg(unix)]
941 fn classify_installed_symlink_dangling() {
942 use std::os::unix::fs::symlink;
943
944 let tmp = tempfile::tempdir().unwrap();
945 let pkg_dir = tmp.path();
946 let dangling_target = PathBuf::from("/nonexistent/path/for/doctor_test");
947 symlink(&dangling_target, pkg_dir.join("p")).unwrap();
948
949 let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir).expect("classify ok");
950 match outcome {
951 DoctorOutcome::SymlinkDangling { reason, suggestion } => {
952 assert!(reason.contains("symlink target missing"), "{reason}");
953 assert!(suggestion.contains("alc_pkg_unlink"), "{suggestion}");
954 }
955 _ => panic!("expected SymlinkDangling"),
956 }
957 }
958
959 #[test]
960 #[cfg(unix)]
961 fn classify_installed_symlink_alive() {
962 use std::os::unix::fs::symlink;
963
964 let tmp = tempfile::tempdir().unwrap();
965 let real_target = tmp.path().join("real_target_dir");
966 std::fs::create_dir(&real_target).unwrap();
967 std::fs::write(
969 real_target.join("init.lua"),
970 "local M = {} M.meta = { name = \"q\", version = \"0.1.0\" } return M",
971 )
972 .unwrap();
973
974 let pkg_dir = tmp.path().join("pkgs");
975 std::fs::create_dir(&pkg_dir).unwrap();
976 symlink(&real_target, pkg_dir.join("q")).unwrap();
977
978 let outcome = classify_installed("q", &mk_entry("/src/q"), &pkg_dir).expect("classify ok");
979 assert!(matches!(outcome, DoctorOutcome::Healthy));
980 }
981
982 #[test]
983 fn buckets_into_json_emits_all_ten_keys() {
984 let mut b = DoctorBuckets::default();
993 b.healthy.push(serde_json::json!({"name": "h"}));
994 b.installed_missing
995 .push(serde_json::json!({"name": "i", "kind": "installed_missing"}));
996 b.symlink_dangling
997 .push(serde_json::json!({"name": "s", "kind": "symlink_dangling"}));
998 b.path_missing
999 .push(serde_json::json!({"name": "p", "kind": "path_missing"}));
1000 b.incomplete_pkg
1001 .push(serde_json::json!({"name": "c", "kind": "incomplete_pkg"}));
1002 b.missing_meta
1003 .push(serde_json::json!({"name": "m", "kind": "missing_meta"}));
1004 b.missing_hub_index
1005 .push(serde_json::json!({"kind": "missing_hub_index", "project_root": "/r"}));
1006 b.spec_missing
1007 .push(serde_json::json!({"name": "sm", "kind": "spec_missing"}));
1008 b.stale_cache
1009 .push(serde_json::json!({"kind": "stale_cache", "path": "/p", "age_secs": 7200}));
1010 b.unregistered_pkg.push(serde_json::json!({
1011 "name": "u",
1012 "kind": "unregistered_pkg",
1013 "source": "unknown",
1014 "reason": "physical dir with init.lua exists but is not registered",
1015 "suggestion": ["install", "link", "rm -rf", "note: unknown source"],
1016 }));
1017
1018 let out = b.into_json();
1019 let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
1020 let obj = parsed.as_object().expect("JSON object");
1021 assert!(obj.contains_key("healthy"));
1022 assert!(obj.contains_key("installed_missing"));
1023 assert!(obj.contains_key("symlink_dangling"));
1024 assert!(obj.contains_key("path_missing"));
1025 assert!(obj.contains_key("incomplete_pkg"));
1026 assert!(obj.contains_key("missing_meta"));
1027 assert!(obj.contains_key("missing_hub_index"));
1028 assert!(obj.contains_key("spec_missing"));
1029 assert!(obj.contains_key("stale_cache"));
1030 assert!(obj.contains_key("unregistered_pkg"));
1031 assert_eq!(obj.len(), 10, "exactly ten top-level buckets: {out}");
1032
1033 assert_eq!(obj["healthy"][0]["name"], "h");
1034 assert_eq!(obj["installed_missing"][0]["name"], "i");
1035 assert_eq!(obj["symlink_dangling"][0]["name"], "s");
1036 assert_eq!(obj["path_missing"][0]["name"], "p");
1037 assert_eq!(obj["incomplete_pkg"][0]["name"], "c");
1038 assert_eq!(obj["missing_meta"][0]["name"], "m");
1039 assert_eq!(obj["missing_hub_index"][0]["project_root"], "/r");
1040 assert_eq!(obj["spec_missing"][0]["name"], "sm");
1041 assert_eq!(obj["stale_cache"][0]["path"], "/p");
1042 assert_eq!(obj["unregistered_pkg"][0]["name"], "u");
1043 assert_eq!(obj["unregistered_pkg"][0]["kind"], "unregistered_pkg");
1044 assert!(
1046 obj["unregistered_pkg"][0]["suggestion"].is_array(),
1047 "unregistered_pkg suggestion must be an array"
1048 );
1049 }
1050
1051 #[test]
1052 fn any_matched_tracks_all_buckets() {
1053 let mut b = DoctorBuckets::default();
1054 assert!(!b.any_matched());
1055 b.healthy.push(serde_json::json!({"name": "h"}));
1056 assert!(b.any_matched());
1057
1058 let mut b = DoctorBuckets::default();
1059 b.installed_missing.push(serde_json::json!({}));
1060 assert!(b.any_matched());
1061
1062 let mut b = DoctorBuckets::default();
1063 b.symlink_dangling.push(serde_json::json!({}));
1064 assert!(b.any_matched());
1065
1066 let mut b = DoctorBuckets::default();
1067 b.path_missing.push(serde_json::json!({}));
1068 assert!(b.any_matched());
1069
1070 let mut b = DoctorBuckets::default();
1071 b.incomplete_pkg.push(serde_json::json!({}));
1072 assert!(b.any_matched());
1073
1074 let mut b = DoctorBuckets::default();
1075 b.missing_meta.push(serde_json::json!({}));
1076 assert!(b.any_matched());
1077
1078 let mut b = DoctorBuckets::default();
1079 b.missing_hub_index.push(serde_json::json!({}));
1080 assert!(b.any_matched());
1081
1082 let mut b = DoctorBuckets::default();
1083 b.spec_missing.push(serde_json::json!({}));
1084 assert!(b.any_matched());
1085
1086 let mut b = DoctorBuckets::default();
1087 b.stale_cache.push(serde_json::json!({}));
1088 assert!(b.any_matched());
1089 }
1090
1091 #[test]
1095 fn check_spec_missing_returns_none_when_spec_file_present() {
1096 let tmp = tempfile::tempdir().unwrap();
1097 let dest = tmp.path().join("mypkg");
1098 std::fs::create_dir_all(dest.join("spec")).unwrap();
1099 std::fs::write(dest.join("spec/foo_spec.lua"), "return {}").unwrap();
1100 let out = check_spec_missing("mypkg", &dest).expect("must not error");
1101 assert!(out.is_none(), "expected None, got: {out:?}");
1102 }
1103
1104 #[test]
1106 fn check_spec_missing_detects_empty_spec_dir() {
1107 let tmp = tempfile::tempdir().unwrap();
1108 let dest = tmp.path().join("mypkg");
1109 std::fs::create_dir_all(dest.join("spec")).unwrap();
1110 let out = check_spec_missing("mypkg", &dest)
1111 .expect("must not error")
1112 .expect("expected SpecMissing");
1113 match out {
1114 DoctorOutcome::SpecMissing { reason, suggestion } => {
1115 assert!(reason.contains("spec"), "reason: {reason}");
1116 assert!(suggestion.contains("_spec.lua"), "suggestion: {suggestion}");
1117 }
1118 _ => panic!("expected SpecMissing, got {out:?}"),
1119 }
1120 }
1121
1122 #[test]
1124 fn check_spec_missing_detects_spec_dir_with_only_non_spec_files() {
1125 let tmp = tempfile::tempdir().unwrap();
1126 let dest = tmp.path().join("mypkg");
1127 std::fs::create_dir_all(dest.join("spec")).unwrap();
1128 std::fs::write(dest.join("spec/helper.lua"), "return {}").unwrap();
1129 std::fs::write(dest.join("spec/README.md"), "docs").unwrap();
1130 let out = check_spec_missing("mypkg", &dest)
1131 .expect("must not error")
1132 .expect("expected SpecMissing");
1133 assert!(matches!(out, DoctorOutcome::SpecMissing { .. }));
1134 }
1135
1136 #[test]
1138 fn check_spec_missing_silently_skips_when_spec_dir_absent() {
1139 let tmp = tempfile::tempdir().unwrap();
1140 let dest = tmp.path().join("mypkg");
1141 std::fs::create_dir_all(&dest).unwrap();
1142 let out = check_spec_missing("mypkg", &dest).expect("must not error");
1143 assert!(
1144 out.is_none(),
1145 "expected None for absent spec/, got: {out:?}"
1146 );
1147 }
1148
1149 #[test]
1152 fn run_stale_cache_pass_emits_when_file_older_than_ttl() {
1153 let tmp = tempfile::tempdir().unwrap();
1154 let cache_dir = tmp.path();
1155 let stale_file = cache_dir.join("abc123.json");
1156 std::fs::write(&stale_file, "{}").unwrap();
1157 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(7200);
1158 let times = std::fs::FileTimes::new().set_modified(past);
1159 let f = std::fs::OpenOptions::new()
1160 .write(true)
1161 .open(&stale_file)
1162 .unwrap();
1163 f.set_times(times).unwrap();
1164
1165 let mut buckets = DoctorBuckets::default();
1166 run_stale_cache_pass(cache_dir, &mut buckets).expect("must not error");
1167 assert_eq!(
1168 buckets.stale_cache.len(),
1169 1,
1170 "expected 1 stale entry: {:?}",
1171 buckets.stale_cache
1172 );
1173 let entry = &buckets.stale_cache[0];
1174 assert_eq!(entry["kind"], "stale_cache");
1175 assert!(entry["path"]
1176 .as_str()
1177 .unwrap_or("")
1178 .ends_with("abc123.json"));
1179 assert!(entry["age_secs"].as_u64().unwrap_or(0) >= 7200);
1180 }
1181
1182 #[test]
1183 fn run_stale_cache_pass_no_emit_for_fresh_file() {
1184 let tmp = tempfile::tempdir().unwrap();
1185 let cache_dir = tmp.path();
1186 let fresh_file = cache_dir.join("xyz789.json");
1187 std::fs::write(&fresh_file, "{}").unwrap();
1188 let mut buckets = DoctorBuckets::default();
1189 run_stale_cache_pass(cache_dir, &mut buckets).expect("must not error");
1190 assert!(
1191 buckets.stale_cache.is_empty(),
1192 "expected no stale entries for fresh file"
1193 );
1194 }
1195
1196 #[test]
1197 fn run_stale_cache_pass_skips_when_cache_dir_absent() {
1198 let tmp = tempfile::tempdir().unwrap();
1199 let missing_dir = tmp.path().join("nonexistent_cache");
1200 let mut buckets = DoctorBuckets::default();
1201 run_stale_cache_pass(&missing_dir, &mut buckets).expect("absent dir must skip with Ok");
1202 assert!(buckets.stale_cache.is_empty());
1203 }
1204
1205 #[test]
1206 fn run_stale_cache_pass_ignores_non_json_files() {
1207 let tmp = tempfile::tempdir().unwrap();
1208 let cache_dir = tmp.path();
1209 let garbage = cache_dir.join(".DS_Store");
1210 std::fs::write(&garbage, "garbage").unwrap();
1211 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(7200);
1212 let times = std::fs::FileTimes::new().set_modified(past);
1213 let f = std::fs::OpenOptions::new()
1214 .write(true)
1215 .open(&garbage)
1216 .unwrap();
1217 f.set_times(times).unwrap();
1218
1219 let mut buckets = DoctorBuckets::default();
1220 run_stale_cache_pass(cache_dir, &mut buckets).expect("must not error");
1221 assert!(
1222 buckets.stale_cache.is_empty(),
1223 "non-json files must be ignored"
1224 );
1225 }
1226
1227 #[test]
1232 fn classify_installed_missing_meta_when_init_lua_lacks_meta() {
1233 let tmp = tempfile::tempdir().unwrap();
1234 let pkg_dir = tmp.path();
1235 let dest = pkg_dir.join("mypkg");
1236 std::fs::create_dir(&dest).unwrap();
1237 std::fs::write(dest.join("init.lua"), "local M = {} return M").unwrap();
1239
1240 let outcome =
1241 classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
1242 match outcome {
1243 DoctorOutcome::MissingMeta { reason, suggestion } => {
1244 assert!(reason.contains("lacks M.meta.name"), "reason: {reason}");
1245 assert!(
1246 suggestion.contains("alc_pkg_install"),
1247 "suggestion: {suggestion}"
1248 );
1249 assert!(
1250 suggestion.contains("mypkg"),
1251 "suggestion carries name: {suggestion}"
1252 );
1253 }
1254 _ => panic!("expected MissingMeta, got {outcome:?}"),
1255 }
1256 }
1257
1258 #[test]
1262 fn classify_installed_missing_meta_when_init_lua_has_empty_meta_name() {
1263 let tmp = tempfile::tempdir().unwrap();
1264 let pkg_dir = tmp.path();
1265 let dest = pkg_dir.join("mypkg");
1266 std::fs::create_dir(&dest).unwrap();
1267 std::fs::write(
1269 dest.join("init.lua"),
1270 "local M = {} M.meta = { name = \"\", version = \"0.1.0\" } return M",
1271 )
1272 .unwrap();
1273
1274 let outcome =
1275 classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
1276 assert!(
1277 matches!(outcome, DoctorOutcome::MissingMeta { .. }),
1278 "expected MissingMeta for empty name, got {outcome:?}"
1279 );
1280 }
1281
1282 #[test]
1285 fn classify_installed_no_missing_meta_when_init_lua_complete() {
1286 let tmp = tempfile::tempdir().unwrap();
1287 let pkg_dir = tmp.path();
1288 let dest = pkg_dir.join("mypkg");
1289 std::fs::create_dir(&dest).unwrap();
1290 std::fs::write(
1291 dest.join("init.lua"),
1292 "local M = {} M.meta = { name = \"mypkg\", version = \"0.1.0\" } return M",
1293 )
1294 .unwrap();
1295
1296 let outcome =
1297 classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
1298 assert!(
1299 matches!(outcome, DoctorOutcome::Healthy),
1300 "expected Healthy for complete init.lua, got {outcome:?}"
1301 );
1302 }
1303
1304 #[test]
1309 fn run_hub_index_pass_emits_when_2_plus_pkgs_and_index_absent() {
1310 let tmp = tempfile::tempdir().unwrap();
1311 let root = tmp.path();
1312 for name in &["pkg_a", "pkg_b"] {
1314 let dir = root.join(name);
1315 std::fs::create_dir(&dir).unwrap();
1316 std::fs::write(dir.join("init.lua"), "return {}").unwrap();
1317 }
1318 let mut buckets = DoctorBuckets::default();
1321 run_hub_index_pass(root, &mut buckets).expect("run_hub_index_pass must not error");
1322
1323 assert_eq!(
1324 buckets.missing_hub_index.len(),
1325 1,
1326 "expected 1 missing_hub_index entry: {:?}",
1327 buckets.missing_hub_index
1328 );
1329 let entry = &buckets.missing_hub_index[0];
1330 assert_eq!(entry["kind"], "missing_hub_index");
1331 assert_eq!(entry["pkg_count"], 2);
1332 assert!(
1333 entry["suggestion"]
1334 .as_str()
1335 .unwrap_or("")
1336 .contains("alc_hub_reindex"),
1337 "suggestion: {entry}"
1338 );
1339 }
1340
1341 #[test]
1344 fn run_hub_index_pass_skips_when_only_1_pkg_dir() {
1345 let tmp = tempfile::tempdir().unwrap();
1346 let root = tmp.path();
1347 let dir = root.join("pkg_a");
1348 std::fs::create_dir(&dir).unwrap();
1349 std::fs::write(dir.join("init.lua"), "return {}").unwrap();
1350 let mut buckets = DoctorBuckets::default();
1353 run_hub_index_pass(root, &mut buckets).expect("run_hub_index_pass must not error");
1354
1355 assert!(
1356 buckets.missing_hub_index.is_empty(),
1357 "must not emit with only 1 pkg dir: {:?}",
1358 buckets.missing_hub_index
1359 );
1360 }
1361
1362 #[test]
1365 fn run_hub_index_pass_skips_when_hub_index_exists() {
1366 let tmp = tempfile::tempdir().unwrap();
1367 let root = tmp.path();
1368 for name in &["pkg_a", "pkg_b"] {
1369 let dir = root.join(name);
1370 std::fs::create_dir(&dir).unwrap();
1371 std::fs::write(dir.join("init.lua"), "return {}").unwrap();
1372 }
1373 std::fs::write(root.join("hub_index.json"), "{}").unwrap();
1375
1376 let mut buckets = DoctorBuckets::default();
1377 run_hub_index_pass(root, &mut buckets).expect("run_hub_index_pass must not error");
1378
1379 assert!(
1380 buckets.missing_hub_index.is_empty(),
1381 "must not emit when hub_index.json exists: {:?}",
1382 buckets.missing_hub_index
1383 );
1384 }
1385
1386 #[test]
1387 fn installed_missing_suggestion_shape() {
1388 let git = PackageSource::Git {
1389 url: "github.com/foo/bar".to_string(),
1390 rev: None,
1391 };
1392 let s = installed_missing_suggestion("ucb", &git);
1393 assert!(s.contains("alc_pkg_install"), "{s}");
1394 assert!(s.contains("\"ucb\""), "{s}");
1395 assert!(s.contains("github.com/foo/bar"), "{s}");
1396 }
1397
1398 #[test]
1403 fn installed_missing_suggestion_routes_bundled_to_alc_init() {
1404 let bundled = PackageSource::Bundled { collection: None };
1405 let s = installed_missing_suggestion("ucb", &bundled);
1406 assert!(s.contains("alc_init"), "bundled must suggest alc_init: {s}");
1407 assert!(
1408 !s.contains("alc_pkg_install"),
1409 "bundled must NOT suggest alc_pkg_install: {s}"
1410 );
1411 }
1412
1413 #[test]
1420 fn installed_missing_suggestion_routes_absolute_path_to_pkg_install() {
1421 let local = PackageSource::Path {
1422 path: "/abs/path/to/src".to_string(),
1423 };
1424 let s = installed_missing_suggestion("local_pkg", &local);
1425 assert!(s.contains("alc_pkg_install"), "{s}");
1426 assert!(s.contains("/abs/path/to/src"), "{s}");
1427 }
1428
1429 #[test]
1433 fn installed_missing_suggestion_routes_unknown_to_reindex() {
1434 let s = installed_missing_suggestion("legacy_pkg", &PackageSource::Unknown);
1435 assert!(
1436 s.contains("alc_hub_reindex"),
1437 "Unknown must suggest alc_hub_reindex: {s}"
1438 );
1439 }
1440
1441 #[test]
1444 fn extract_subs_double_quote() {
1445 let src = r#"
1446local M = {}
1447local check = require("mypkg.check")
1448local t = require("mypkg.t")
1449return M
1450"#;
1451 let subs = extract_required_subs(src, "mypkg");
1452 assert_eq!(subs, vec!["check", "t"]);
1453 }
1454
1455 #[test]
1456 fn extract_subs_single_quote() {
1457 let src = "local x = require('mypkg.sub')";
1458 let subs = extract_required_subs(src, "mypkg");
1459 assert_eq!(subs, vec!["sub"]);
1460 }
1461
1462 #[test]
1463 fn extract_subs_ignores_other_packages() {
1464 let src = r#"
1465local x = require("other.sub")
1466local y = require("mypkg.mine")
1467"#;
1468 let subs = extract_required_subs(src, "mypkg");
1469 assert_eq!(subs, vec!["mine"]);
1470 }
1471
1472 #[test]
1473 fn extract_subs_deduplicates() {
1474 let src = r#"
1475local a = require("mypkg.check")
1476local b = require("mypkg.check")
1477"#;
1478 let subs = extract_required_subs(src, "mypkg");
1479 assert_eq!(subs, vec!["check"]);
1480 }
1481
1482 #[test]
1483 fn extract_subs_ignores_dynamic_require() {
1484 let src = r#"local x = require(mod_name)"#;
1486 let subs = extract_required_subs(src, "mypkg");
1487 assert!(subs.is_empty(), "dynamic require must be ignored: {subs:?}");
1488 }
1489
1490 #[test]
1491 fn extract_subs_ignores_nested_dots() {
1492 let src = r#"local x = require("mypkg.sub.deeper")"#;
1494 let subs = extract_required_subs(src, "mypkg");
1495 assert!(
1496 subs.is_empty(),
1497 "nested dotted require must be ignored: {subs:?}"
1498 );
1499 }
1500
1501 #[test]
1502 fn extract_subs_empty_for_no_require() {
1503 let src = r#"local M = {} return M"#;
1504 let subs = extract_required_subs(src, "mypkg");
1505 assert!(subs.is_empty());
1506 }
1507
1508 #[test]
1511 fn check_incomplete_returns_none_when_all_subs_present_as_lua() {
1512 let tmp = tempfile::tempdir().unwrap();
1513 let dest = tmp.path().join("mypkg");
1514 std::fs::create_dir(&dest).unwrap();
1515 std::fs::write(
1516 dest.join("init.lua"),
1517 r#"local c = require("mypkg.check") return {}"#,
1518 )
1519 .unwrap();
1520 std::fs::write(dest.join("check.lua"), "return {}").unwrap();
1521
1522 assert!(check_incomplete("mypkg", &dest, false).is_none());
1523 }
1524
1525 #[test]
1526 fn check_incomplete_returns_none_when_sub_is_dir_init() {
1527 let tmp = tempfile::tempdir().unwrap();
1528 let dest = tmp.path().join("mypkg");
1529 std::fs::create_dir(&dest).unwrap();
1530 std::fs::write(
1531 dest.join("init.lua"),
1532 r#"local c = require("mypkg.sub") return {}"#,
1533 )
1534 .unwrap();
1535 std::fs::create_dir(dest.join("sub")).unwrap();
1537 std::fs::write(dest.join("sub").join("init.lua"), "return {}").unwrap();
1538
1539 assert!(check_incomplete("mypkg", &dest, false).is_none());
1540 }
1541
1542 #[test]
1543 fn check_incomplete_detects_missing_sub() {
1544 let tmp = tempfile::tempdir().unwrap();
1545 let dest = tmp.path().join("mypkg");
1546 std::fs::create_dir(&dest).unwrap();
1547 std::fs::write(
1548 dest.join("init.lua"),
1549 r#"
1550local check = require("mypkg.check")
1551local t = require("mypkg.t")
1552return {}
1553"#,
1554 )
1555 .unwrap();
1556 std::fs::write(dest.join("check.lua"), "return {}").unwrap();
1558
1559 let outcome = check_incomplete("mypkg", &dest, false).expect("should detect incomplete");
1560 match outcome {
1561 DoctorOutcome::IncompletePkg {
1562 missing_subs,
1563 suggestion,
1564 } => {
1565 assert_eq!(missing_subs, vec!["t"], "missing_subs: {missing_subs:?}");
1566 assert!(
1567 suggestion.contains("alc_pkg_install"),
1568 "non-symlink suggestion: {suggestion}"
1569 );
1570 }
1571 _ => panic!("expected IncompletePkg"),
1572 }
1573 }
1574
1575 #[test]
1576 fn check_incomplete_suggestion_uses_link_for_symlink() {
1577 let tmp = tempfile::tempdir().unwrap();
1578 let dest = tmp.path().join("mypkg");
1579 std::fs::create_dir(&dest).unwrap();
1580 std::fs::write(
1581 dest.join("init.lua"),
1582 r#"local x = require("mypkg.missing") return {}"#,
1583 )
1584 .unwrap();
1585 let outcome = check_incomplete("mypkg", &dest, true).expect("should detect incomplete");
1588 match outcome {
1589 DoctorOutcome::IncompletePkg { suggestion, .. } => {
1590 assert!(
1591 suggestion.contains("alc_pkg_link"),
1592 "symlink suggestion: {suggestion}"
1593 );
1594 }
1595 _ => panic!("expected IncompletePkg"),
1596 }
1597 }
1598
1599 #[test]
1600 fn check_incomplete_returns_none_when_no_init_lua() {
1601 let tmp = tempfile::tempdir().unwrap();
1603 let dest = tmp.path().join("mypkg");
1604 std::fs::create_dir(&dest).unwrap();
1605
1606 assert!(check_incomplete("mypkg", &dest, false).is_none());
1607 }
1608
1609 #[test]
1610 fn classify_installed_incomplete_pkg() {
1611 let tmp = tempfile::tempdir().unwrap();
1613 let pkg_dir = tmp.path();
1614 let dest = pkg_dir.join("mypkg");
1615 std::fs::create_dir(&dest).unwrap();
1616 std::fs::write(
1617 dest.join("init.lua"),
1618 r#"local x = require("mypkg.sub") return {}"#,
1619 )
1620 .unwrap();
1621 let outcome =
1624 classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
1625 match outcome {
1626 DoctorOutcome::IncompletePkg {
1627 missing_subs,
1628 suggestion,
1629 } => {
1630 assert_eq!(missing_subs, vec!["sub"]);
1631 assert!(suggestion.contains("alc_pkg_install"), "{suggestion}");
1632 }
1633 _ => panic!("expected IncompletePkg, got {outcome:?}"),
1634 }
1635 }
1636
1637 #[test]
1638 fn classify_installed_healthy_when_all_subs_present() {
1639 let tmp = tempfile::tempdir().unwrap();
1642 let pkg_dir = tmp.path();
1643 let dest = pkg_dir.join("mypkg");
1644 std::fs::create_dir(&dest).unwrap();
1645 std::fs::write(
1646 dest.join("init.lua"),
1647 "local M = {}\n\
1648 M.meta = { name = \"mypkg\", version = \"0.1.0\" }\n\
1649 local x = require(\"mypkg.sub\")\n\
1650 return M",
1651 )
1652 .unwrap();
1653 std::fs::write(dest.join("sub.lua"), "return {}").unwrap();
1654
1655 let outcome =
1656 classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
1657 assert!(
1658 matches!(outcome, DoctorOutcome::Healthy),
1659 "expected Healthy, got {outcome:?}"
1660 );
1661 }
1662}