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, AliveBucket, 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_spec_missing(name: &str, dest: &Path) -> Result<Option<DoctorOutcome>, String> {
360 let spec_dir = dest.join("spec");
361 if !spec_dir.is_dir() {
362 return Ok(None);
363 }
364 let entries = std::fs::read_dir(&spec_dir).map_err(|e| {
365 format!(
366 "spec_missing: failed to read_dir {}: {e}",
367 spec_dir.display()
368 )
369 })?;
370 let mut found_spec = false;
371 for entry in entries {
372 let entry = entry.map_err(|e| format!("spec_missing: failed to read dir entry: {e}"))?;
373 let ft = entry.file_type().map_err(|e| {
374 format!(
375 "spec_missing: failed to read file_type for {}: {e}",
376 entry.path().display()
377 )
378 })?;
379 if !ft.is_file() {
380 continue;
381 }
382 let fname = entry.file_name();
383 if fname.to_string_lossy().ends_with("_spec.lua") {
384 found_spec = true;
385 break;
386 }
387 }
388 if found_spec {
389 return Ok(None);
390 }
391 Ok(Some(DoctorOutcome::SpecMissing {
392 reason: format!(
393 "spec directory at {} exists but contains zero *_spec.lua files",
394 spec_dir.display()
395 ),
396 suggestion: format!(
397 "Package {name:?} declared test intent by creating spec/ at {} — \
398 add at least one <name>_spec.lua file (mlua-lspec convention) or remove \
399 the spec/ directory to opt out of spec discipline",
400 spec_dir.display()
401 ),
402 }))
403}
404
405fn classify_installed(
416 name: &str,
417 entry: &ManifestEntry,
418 pkg_dir: &Path,
419) -> Result<DoctorOutcome, String> {
420 let dest = pkg_dir.join(name);
421
422 let is_symlink = dest
423 .symlink_metadata()
424 .map(|m| m.file_type().is_symlink())
425 .unwrap_or(false);
426 if is_symlink {
427 let target_alive = match dest.try_exists() {
429 Ok(v) => v,
430 Err(e) => {
431 warn!(error = %e, path = %dest.display(), "try_exists failed; treating symlink target as dead");
432 false
433 }
434 };
435 if !target_alive {
436 let link_target = match dest.read_link() {
437 Ok(t) => t.display().to_string(),
438 Err(e) => {
439 warn!(error = %e, path = %dest.display(), "read_link failed; using placeholder for dangling target");
440 "<unknown>".to_string()
441 }
442 };
443 return Ok(DoctorOutcome::SymlinkDangling {
444 reason: format!("symlink target missing: {link_target}"),
445 suggestion: symlink_dangling_suggestion(name),
446 });
447 }
448 let init_lua = dest.join("init.lua");
450 let entity = PkgEntity::parse_from_init_lua(&init_lua);
451 if let Some(incomplete) = check_incomplete(name, &dest, true) {
452 return Ok(incomplete);
453 }
454 if entity.is_none() {
455 return Ok(DoctorOutcome::MissingMeta {
456 reason: format!("init.lua at {} lacks M.meta.name", init_lua.display()),
457 suggestion: format!(
458 "Package directory at {} lacks M.meta.name in init.lua — \
459 run alc_pkg_install --force {name:?} or fix init.lua to declare \
460 M.meta = {{ name = ..., version = ... }}",
461 dest.display()
462 ),
463 });
464 }
465 if entity.is_some() {
467 if let Some(sm) = check_spec_missing(name, &dest)? {
468 return Ok(sm);
469 }
470 }
471 return Ok(DoctorOutcome::Healthy);
472 }
473
474 if dest.exists() {
475 let init_lua = dest.join("init.lua");
477 let entity = PkgEntity::parse_from_init_lua(&init_lua);
478 if let Some(incomplete) = check_incomplete(name, &dest, false) {
479 return Ok(incomplete);
480 }
481 if entity.is_none() {
482 return Ok(DoctorOutcome::MissingMeta {
483 reason: format!("init.lua at {} lacks M.meta.name", init_lua.display()),
484 suggestion: format!(
485 "Package directory at {} lacks M.meta.name in init.lua — \
486 run alc_pkg_install --force {name:?} or fix init.lua to declare \
487 M.meta = {{ name = ..., version = ... }}",
488 dest.display()
489 ),
490 });
491 }
492 if entity.is_some() {
494 if let Some(sm) = check_spec_missing(name, &dest)? {
495 return Ok(sm);
496 }
497 }
498 return Ok(DoctorOutcome::Healthy);
499 }
500
501 Ok(DoctorOutcome::InstalledMissing {
502 reason: format!("installed directory missing: {}", dest.display()),
503 suggestion: installed_missing_suggestion(name, &entry.source),
504 })
505}
506
507fn run_manifest_pass(
511 manifest: &Manifest,
512 target_filter: Option<&str>,
513 pkg_dir: &Path,
514 buckets: &mut DoctorBuckets,
515) -> Result<(), String> {
516 if let Some(target) = target_filter {
517 if let Some(entry) = manifest.packages.get(target) {
518 let outcome = classify_installed(target, entry, pkg_dir)?;
519 push_doctor_outcome(target, outcome, buckets);
520 }
521 return Ok(());
522 }
523 for (pkg_name, entry) in &manifest.packages {
524 let outcome = classify_installed(pkg_name, entry, pkg_dir)?;
525 push_doctor_outcome(pkg_name, outcome, buckets);
526 }
527 Ok(())
528}
529
530fn run_unattached_symlink_pass(
534 pkg_dir: &Path,
535 target_filter: Option<&str>,
536 manifest: &Manifest,
537 buckets: &mut DoctorBuckets,
538) {
539 let mut scratch: Vec<serde_json::Value> = Vec::new();
540 collect_unattached_dangling_symlinks(pkg_dir, target_filter, &manifest.packages, &mut scratch);
541 buckets.symlink_dangling.extend(scratch);
542}
543
544fn run_path_missing_pass(
548 resolved_root: Option<&Path>,
549 target_filter: Option<&str>,
550 buckets: &mut DoctorBuckets,
551) {
552 let Some(root) = resolved_root else {
553 return;
554 };
555 let mut scratch: Vec<serde_json::Value> = Vec::new();
556 collect_path_missing(
557 root,
558 target_filter,
559 "project",
560 &mut scratch,
561 ProjectPathSource::Toml,
562 );
563 collect_path_missing(
564 root,
565 target_filter,
566 "variant",
567 &mut scratch,
568 ProjectPathSource::Local,
569 );
570 buckets.path_missing.extend(scratch);
571}
572
573fn run_hub_index_pass(root: &Path, buckets: &mut DoctorBuckets) -> Result<(), String> {
591 let mut pkg_count = 0usize;
592 let entries = std::fs::read_dir(root).map_err(|e| {
593 format!(
594 "hub_index_pass: failed to read project_root {}: {e}",
595 root.display()
596 )
597 })?;
598 for entry in entries {
599 let entry = entry.map_err(|e| format!("hub_index_pass: failed to read dir entry: {e}"))?;
600 let ft = entry
601 .file_type()
602 .map_err(|e| format!("hub_index_pass: failed to read file_type: {e}"))?;
603 if !ft.is_dir() {
604 continue;
605 }
606 let init_lua = entry.path().join("init.lua");
607 let exists = init_lua.try_exists().map_err(|e| {
608 format!(
609 "hub_index_pass: try_exists failed for {}: {e}",
610 init_lua.display()
611 )
612 })?;
613 if exists {
614 pkg_count += 1;
615 }
616 }
617 if pkg_count < 2 {
618 return Ok(());
619 }
620 let hub_index = root.join("hub_index.json");
621 let has_index = hub_index.try_exists().map_err(|e| {
622 format!(
623 "hub_index_pass: try_exists failed for {}: {e}",
624 hub_index.display()
625 )
626 })?;
627 if has_index {
628 return Ok(());
629 }
630 buckets.missing_hub_index.push(serde_json::json!({
631 "kind": "missing_hub_index",
632 "project_root": root.display().to_string(),
633 "pkg_count": pkg_count,
634 "suggestion": format!(
635 "Collection project root contains {pkg_count} package dirs but \
636 {}/hub_index.json is missing — run alc_hub_reindex --source_dir {} \
637 to generate it",
638 root.display(),
639 root.display()
640 ),
641 }));
642 Ok(())
643}
644
645fn run_stale_cache_pass(cache_dir: &Path, buckets: &mut DoctorBuckets) -> Result<(), String> {
661 let exists = cache_dir.try_exists().map_err(|e| {
662 format!(
663 "stale_cache_pass: try_exists failed for {}: {e}",
664 cache_dir.display()
665 )
666 })?;
667 if !exists {
668 return Ok(());
669 }
670 let entries = std::fs::read_dir(cache_dir).map_err(|e| {
671 format!(
672 "stale_cache_pass: failed to read_dir {}: {e}",
673 cache_dir.display()
674 )
675 })?;
676 for entry in entries {
677 let entry =
678 entry.map_err(|e| format!("stale_cache_pass: failed to read dir entry: {e}"))?;
679 let ft = entry.file_type().map_err(|e| {
680 format!(
681 "stale_cache_pass: failed to read file_type for {}: {e}",
682 entry.path().display()
683 )
684 })?;
685 if !ft.is_file() {
686 continue;
687 }
688 let path = entry.path();
689 if path.extension().and_then(|s| s.to_str()) != Some("json") {
690 continue;
691 }
692 let metadata = entry.metadata().map_err(|e| {
693 format!(
694 "stale_cache_pass: failed to read metadata for {}: {e}",
695 path.display()
696 )
697 })?;
698 let Some(modified) = metadata.modified().ok() else {
701 continue;
702 };
703 let Some(age) = modified.elapsed().ok() else {
704 continue;
705 };
706 if age.as_secs() <= DOCTOR_CACHE_TTL_SECS {
707 continue;
708 }
709 buckets.stale_cache.push(serde_json::json!({
710 "kind": "stale_cache",
711 "path": path.display().to_string(),
712 "age_secs": age.as_secs(),
713 "suggestion": format!(
714 "Run alc_hub_search to refresh stale cache (>{DOCTOR_CACHE_TTL_SECS}s old)"
715 ),
716 }));
717 }
718 Ok(())
719}
720
721fn run_unregistered_pkg_pass(
740 pkg_dir: &Path,
741 registered: &HashSet<String>,
742 registered_paths: &[PathBuf],
743 target_filter: Option<&str>,
744 buckets: &mut DoctorBuckets,
745) -> Result<(), String> {
746 let found =
747 collect_unregistered_pkg_dirs(pkg_dir, registered, registered_paths, target_filter)?;
748 buckets.unregistered_pkg.extend(found);
749 Ok(())
750}
751
752impl AppService {
753 async fn run_alive_unregistered_symlink_pass(
777 &self,
778 pkg_dir: &Path,
779 registered: &HashSet<String>,
780 registered_paths: &[PathBuf],
781 target_filter: Option<&str>,
782 buckets: &mut DoctorBuckets,
783 ) -> Result<(), String> {
784 let found = self
785 .collect_alive_unregistered_symlinks(
786 pkg_dir,
787 registered,
788 registered_paths,
789 target_filter,
790 )
791 .await?;
792 for (name, bucket) in found {
793 match bucket {
794 AliveBucket::Unregistered => {
795 let abs_path = pkg_dir.join(&name).display().to_string();
796 let suggestion = serde_json::json!([
797 format!(
798 "If this pkg was scaffolded outside `alc_pkg_scaffold` and you want it installed: \
799 `alc_pkg_install --force {abs_path}` (re-copy + register in installed.json)"
800 ),
801 format!(
802 "If you are actively iterating on this pkg in-tree: \
803 `alc_pkg_link {abs_path}` (symlink-based, no copy)"
804 ),
805 format!("If this dir is stale/abandoned: `rm -rf {abs_path}` to clean it up"),
806 "Note: source is unknown — git URL cannot be inferred from the bare directory. \
807 Re-record via one of the above."
808 .to_string(),
809 ]);
810 buckets.unregistered_pkg.push(serde_json::json!({
811 "name": name,
812 "kind": "unregistered_pkg",
813 "source": "unknown",
814 "reason": format!(
815 "alive symlink with init.lua exists but is not registered in \
816 installed.json, alc.toml, or alc.local.toml: <symlink path: '{abs_path}'>"
817 ),
818 "suggestion": suggestion,
819 }));
820 }
821 }
822 }
823 Ok(())
824 }
825}
826
827impl AppService {
828 pub async fn pkg_doctor(
856 &self,
857 name: Option<String>,
858 project_root: Option<String>,
859 ) -> Result<String, String> {
860 let app_dir = self.log_config.app_dir();
861 let manifest = load_manifest(&app_dir)?;
862 let pkg_dir = packages_dir(&app_dir);
863 let resolved_root = self.resolve_root(project_root.as_deref());
864 let target_filter = name.as_deref();
865
866 let mut registered: HashSet<String> = manifest.packages.keys().cloned().collect();
871 let mut registered_paths: Vec<PathBuf> = Vec::new();
872
873 if let Some(ref root) = resolved_root {
874 if let Some(toml_data) = load_alc_toml(root)? {
876 for (name, dep) in &toml_data.packages {
877 registered.insert(name.clone());
878 if let PackageDep::Path { path, .. } = dep {
879 let raw = std::path::Path::new(path);
880 let abs = if raw.is_absolute() {
881 raw.to_path_buf()
882 } else {
883 root.join(raw)
884 };
885 match abs.canonicalize() {
886 Ok(c) => registered_paths.push(c),
887 Err(e) => {
888 tracing::warn!(
894 "pkg: cannot canonicalize alc.toml path entry \
895 for '{}' ({}): {e}",
896 name,
897 abs.display()
898 );
899 }
900 }
901 }
902 }
903 }
904 if let Some(local_data) = load_alc_local_toml(root)? {
906 for (name, dep) in &local_data.packages {
907 registered.insert(name.clone());
908 if let PackageDep::Path { path, .. } = dep {
909 let raw = std::path::Path::new(path);
910 let abs = if raw.is_absolute() {
911 raw.to_path_buf()
912 } else {
913 root.join(raw)
914 };
915 match abs.canonicalize() {
916 Ok(c) => registered_paths.push(c),
917 Err(e) => {
918 tracing::warn!(
919 "pkg: cannot canonicalize alc.local.toml path entry \
920 for '{}' ({}): {e}",
921 name,
922 abs.display()
923 );
924 }
925 }
926 }
927 }
928 }
929 }
930
931 let mut buckets = DoctorBuckets::default();
932 run_manifest_pass(&manifest, target_filter, &pkg_dir, &mut buckets)?;
933 run_unattached_symlink_pass(&pkg_dir, target_filter, &manifest, &mut buckets);
934 run_path_missing_pass(resolved_root.as_deref(), target_filter, &mut buckets);
935 run_unregistered_pkg_pass(
936 &pkg_dir,
937 ®istered,
938 ®istered_paths,
939 target_filter,
940 &mut buckets,
941 )?;
942 self.run_alive_unregistered_symlink_pass(
943 &pkg_dir,
944 ®istered,
945 ®istered_paths,
946 target_filter,
947 &mut buckets,
948 )
949 .await?;
950 if target_filter.is_none() {
951 run_stale_cache_pass(&app_dir.hub_cache_dir(), &mut buckets)?;
952 if let Some(ref root) = resolved_root {
953 run_hub_index_pass(root, &mut buckets)?;
954 }
955 }
956
957 if let Some(target) = target_filter {
958 if !buckets.any_matched() {
959 return Err(format!(
960 "Package '{target}' not found in installed.json, ~/.algocline/packages/, alc.toml, or alc.local.toml"
961 ));
962 }
963 }
964
965 Ok(buckets.into_json())
966 }
967}
968
969#[cfg(test)]
970mod tests {
971 use super::*;
972 use std::path::PathBuf;
973
974 use crate::service::test_support::make_app_service_at;
975
976 fn mk_entry(source: &str) -> ManifestEntry {
980 ManifestEntry {
981 version: None,
982 source: PackageSource::Path {
983 path: source.to_string(),
984 },
985 installed_at: "2026-01-01T00:00:00Z".to_string(),
986 updated_at: "2026-01-01T00:00:00Z".to_string(),
987 pkg_type: None,
988 }
989 }
990
991 #[test]
992 fn classify_installed_healthy_dir() {
993 let tmp = tempfile::tempdir().unwrap();
994 let pkg_dir = tmp.path();
995 let dest = pkg_dir.join("p");
996 std::fs::create_dir(&dest).unwrap();
997 std::fs::write(
999 dest.join("init.lua"),
1000 "local M = {} M.meta = { name = \"p\", version = \"0.1.0\" } return M",
1001 )
1002 .unwrap();
1003
1004 let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir).expect("classify ok");
1005 assert!(matches!(outcome, DoctorOutcome::Healthy));
1006 }
1007
1008 #[test]
1009 fn classify_installed_missing_dir() {
1010 let tmp = tempfile::tempdir().unwrap();
1011 let pkg_dir = tmp.path();
1012
1013 let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir).expect("classify ok");
1014 match outcome {
1015 DoctorOutcome::InstalledMissing { reason, suggestion } => {
1016 assert!(
1017 reason.contains("installed directory missing"),
1018 "reason = {reason}"
1019 );
1020 assert!(
1021 suggestion.contains("alc_pkg_install"),
1022 "suggestion = {suggestion}"
1023 );
1024 assert!(
1025 suggestion.contains("/src/p"),
1026 "suggestion carries source: {suggestion}"
1027 );
1028 }
1029 _ => panic!("expected InstalledMissing"),
1030 }
1031 }
1032
1033 #[test]
1034 #[cfg(unix)]
1035 fn classify_installed_symlink_dangling() {
1036 use std::os::unix::fs::symlink;
1037
1038 let tmp = tempfile::tempdir().unwrap();
1039 let pkg_dir = tmp.path();
1040 let dangling_target = PathBuf::from("/nonexistent/path/for/doctor_test");
1041 symlink(&dangling_target, pkg_dir.join("p")).unwrap();
1042
1043 let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir).expect("classify ok");
1044 match outcome {
1045 DoctorOutcome::SymlinkDangling { reason, suggestion } => {
1046 assert!(reason.contains("symlink target missing"), "{reason}");
1047 assert!(suggestion.contains("alc_pkg_unlink"), "{suggestion}");
1048 }
1049 _ => panic!("expected SymlinkDangling"),
1050 }
1051 }
1052
1053 #[test]
1054 #[cfg(unix)]
1055 fn classify_installed_symlink_alive() {
1056 use std::os::unix::fs::symlink;
1057
1058 let tmp = tempfile::tempdir().unwrap();
1059 let real_target = tmp.path().join("real_target_dir");
1060 std::fs::create_dir(&real_target).unwrap();
1061 std::fs::write(
1063 real_target.join("init.lua"),
1064 "local M = {} M.meta = { name = \"q\", version = \"0.1.0\" } return M",
1065 )
1066 .unwrap();
1067
1068 let pkg_dir = tmp.path().join("pkgs");
1069 std::fs::create_dir(&pkg_dir).unwrap();
1070 symlink(&real_target, pkg_dir.join("q")).unwrap();
1071
1072 let outcome = classify_installed("q", &mk_entry("/src/q"), &pkg_dir).expect("classify ok");
1073 assert!(matches!(outcome, DoctorOutcome::Healthy));
1074 }
1075
1076 #[test]
1077 fn buckets_into_json_emits_all_ten_keys() {
1078 let mut b = DoctorBuckets::default();
1088 b.healthy.push(serde_json::json!({"name": "h"}));
1089 b.installed_missing
1090 .push(serde_json::json!({"name": "i", "kind": "installed_missing"}));
1091 b.symlink_dangling
1092 .push(serde_json::json!({"name": "s", "kind": "symlink_dangling"}));
1093 b.path_missing
1094 .push(serde_json::json!({"name": "p", "kind": "path_missing"}));
1095 b.incomplete_pkg
1096 .push(serde_json::json!({"name": "c", "kind": "incomplete_pkg"}));
1097 b.missing_meta
1098 .push(serde_json::json!({"name": "m", "kind": "missing_meta"}));
1099 b.missing_hub_index
1100 .push(serde_json::json!({"kind": "missing_hub_index", "project_root": "/r"}));
1101 b.spec_missing
1102 .push(serde_json::json!({"name": "sm", "kind": "spec_missing"}));
1103 b.stale_cache
1104 .push(serde_json::json!({"kind": "stale_cache", "path": "/p", "age_secs": 7200}));
1105 b.unregistered_pkg.push(serde_json::json!({
1106 "name": "u",
1107 "kind": "unregistered_pkg",
1108 "source": "unknown",
1109 "reason": "physical dir with init.lua exists but is not registered",
1110 "suggestion": ["install", "link", "rm -rf", "note: unknown source"],
1111 }));
1112
1113 let out = b.into_json();
1114 let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
1115 let obj = parsed.as_object().expect("JSON object");
1116 assert!(obj.contains_key("healthy"));
1117 assert!(obj.contains_key("installed_missing"));
1118 assert!(obj.contains_key("symlink_dangling"));
1119 assert!(obj.contains_key("path_missing"));
1120 assert!(obj.contains_key("incomplete_pkg"));
1121 assert!(obj.contains_key("missing_meta"));
1122 assert!(obj.contains_key("missing_hub_index"));
1123 assert!(obj.contains_key("spec_missing"));
1124 assert!(obj.contains_key("stale_cache"));
1125 assert!(obj.contains_key("unregistered_pkg"));
1126 assert!(
1127 !obj.contains_key("unmarked_library"),
1128 "unmarked_library must not be emitted"
1129 );
1130 assert_eq!(obj.len(), 10, "exactly ten top-level buckets: {out}");
1131
1132 assert_eq!(obj["healthy"][0]["name"], "h");
1133 assert_eq!(obj["installed_missing"][0]["name"], "i");
1134 assert_eq!(obj["symlink_dangling"][0]["name"], "s");
1135 assert_eq!(obj["path_missing"][0]["name"], "p");
1136 assert_eq!(obj["incomplete_pkg"][0]["name"], "c");
1137 assert_eq!(obj["missing_meta"][0]["name"], "m");
1138 assert_eq!(obj["missing_hub_index"][0]["project_root"], "/r");
1139 assert_eq!(obj["spec_missing"][0]["name"], "sm");
1140 assert_eq!(obj["stale_cache"][0]["path"], "/p");
1141 assert_eq!(obj["unregistered_pkg"][0]["name"], "u");
1142 assert_eq!(obj["unregistered_pkg"][0]["kind"], "unregistered_pkg");
1143 assert!(
1145 obj["unregistered_pkg"][0]["suggestion"].is_array(),
1146 "unregistered_pkg suggestion must be an array"
1147 );
1148 }
1149
1150 #[test]
1151 fn any_matched_tracks_all_buckets() {
1152 let mut b = DoctorBuckets::default();
1153 assert!(!b.any_matched());
1154 b.healthy.push(serde_json::json!({"name": "h"}));
1155 assert!(b.any_matched());
1156
1157 let mut b = DoctorBuckets::default();
1158 b.installed_missing.push(serde_json::json!({}));
1159 assert!(b.any_matched());
1160
1161 let mut b = DoctorBuckets::default();
1162 b.symlink_dangling.push(serde_json::json!({}));
1163 assert!(b.any_matched());
1164
1165 let mut b = DoctorBuckets::default();
1166 b.path_missing.push(serde_json::json!({}));
1167 assert!(b.any_matched());
1168
1169 let mut b = DoctorBuckets::default();
1170 b.incomplete_pkg.push(serde_json::json!({}));
1171 assert!(b.any_matched());
1172
1173 let mut b = DoctorBuckets::default();
1174 b.missing_meta.push(serde_json::json!({}));
1175 assert!(b.any_matched());
1176
1177 let mut b = DoctorBuckets::default();
1178 b.missing_hub_index.push(serde_json::json!({}));
1179 assert!(b.any_matched());
1180
1181 let mut b = DoctorBuckets::default();
1182 b.spec_missing.push(serde_json::json!({}));
1183 assert!(b.any_matched());
1184
1185 let mut b = DoctorBuckets::default();
1186 b.stale_cache.push(serde_json::json!({}));
1187 assert!(b.any_matched());
1188 }
1189
1190 #[test]
1194 fn check_spec_missing_returns_none_when_spec_file_present() {
1195 let tmp = tempfile::tempdir().unwrap();
1196 let dest = tmp.path().join("mypkg");
1197 std::fs::create_dir_all(dest.join("spec")).unwrap();
1198 std::fs::write(dest.join("spec/foo_spec.lua"), "return {}").unwrap();
1199 let out = check_spec_missing("mypkg", &dest).expect("must not error");
1200 assert!(out.is_none(), "expected None, got: {out:?}");
1201 }
1202
1203 #[test]
1205 fn check_spec_missing_detects_empty_spec_dir() {
1206 let tmp = tempfile::tempdir().unwrap();
1207 let dest = tmp.path().join("mypkg");
1208 std::fs::create_dir_all(dest.join("spec")).unwrap();
1209 let out = check_spec_missing("mypkg", &dest)
1210 .expect("must not error")
1211 .expect("expected SpecMissing");
1212 match out {
1213 DoctorOutcome::SpecMissing { reason, suggestion } => {
1214 assert!(reason.contains("spec"), "reason: {reason}");
1215 assert!(suggestion.contains("_spec.lua"), "suggestion: {suggestion}");
1216 }
1217 _ => panic!("expected SpecMissing, got {out:?}"),
1218 }
1219 }
1220
1221 #[test]
1223 fn check_spec_missing_detects_spec_dir_with_only_non_spec_files() {
1224 let tmp = tempfile::tempdir().unwrap();
1225 let dest = tmp.path().join("mypkg");
1226 std::fs::create_dir_all(dest.join("spec")).unwrap();
1227 std::fs::write(dest.join("spec/helper.lua"), "return {}").unwrap();
1228 std::fs::write(dest.join("spec/README.md"), "docs").unwrap();
1229 let out = check_spec_missing("mypkg", &dest)
1230 .expect("must not error")
1231 .expect("expected SpecMissing");
1232 assert!(matches!(out, DoctorOutcome::SpecMissing { .. }));
1233 }
1234
1235 #[test]
1237 fn check_spec_missing_silently_skips_when_spec_dir_absent() {
1238 let tmp = tempfile::tempdir().unwrap();
1239 let dest = tmp.path().join("mypkg");
1240 std::fs::create_dir_all(&dest).unwrap();
1241 let out = check_spec_missing("mypkg", &dest).expect("must not error");
1242 assert!(
1243 out.is_none(),
1244 "expected None for absent spec/, got: {out:?}"
1245 );
1246 }
1247
1248 #[test]
1251 fn run_stale_cache_pass_emits_when_file_older_than_ttl() {
1252 let tmp = tempfile::tempdir().unwrap();
1253 let cache_dir = tmp.path();
1254 let stale_file = cache_dir.join("abc123.json");
1255 std::fs::write(&stale_file, "{}").unwrap();
1256 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(7200);
1257 let times = std::fs::FileTimes::new().set_modified(past);
1258 let f = std::fs::OpenOptions::new()
1259 .write(true)
1260 .open(&stale_file)
1261 .unwrap();
1262 f.set_times(times).unwrap();
1263
1264 let mut buckets = DoctorBuckets::default();
1265 run_stale_cache_pass(cache_dir, &mut buckets).expect("must not error");
1266 assert_eq!(
1267 buckets.stale_cache.len(),
1268 1,
1269 "expected 1 stale entry: {:?}",
1270 buckets.stale_cache
1271 );
1272 let entry = &buckets.stale_cache[0];
1273 assert_eq!(entry["kind"], "stale_cache");
1274 assert!(entry["path"]
1275 .as_str()
1276 .unwrap_or("")
1277 .ends_with("abc123.json"));
1278 assert!(entry["age_secs"].as_u64().unwrap_or(0) >= 7200);
1279 }
1280
1281 #[test]
1282 fn run_stale_cache_pass_no_emit_for_fresh_file() {
1283 let tmp = tempfile::tempdir().unwrap();
1284 let cache_dir = tmp.path();
1285 let fresh_file = cache_dir.join("xyz789.json");
1286 std::fs::write(&fresh_file, "{}").unwrap();
1287 let mut buckets = DoctorBuckets::default();
1288 run_stale_cache_pass(cache_dir, &mut buckets).expect("must not error");
1289 assert!(
1290 buckets.stale_cache.is_empty(),
1291 "expected no stale entries for fresh file"
1292 );
1293 }
1294
1295 #[test]
1296 fn run_stale_cache_pass_skips_when_cache_dir_absent() {
1297 let tmp = tempfile::tempdir().unwrap();
1298 let missing_dir = tmp.path().join("nonexistent_cache");
1299 let mut buckets = DoctorBuckets::default();
1300 run_stale_cache_pass(&missing_dir, &mut buckets).expect("absent dir must skip with Ok");
1301 assert!(buckets.stale_cache.is_empty());
1302 }
1303
1304 #[test]
1305 fn run_stale_cache_pass_ignores_non_json_files() {
1306 let tmp = tempfile::tempdir().unwrap();
1307 let cache_dir = tmp.path();
1308 let garbage = cache_dir.join(".DS_Store");
1309 std::fs::write(&garbage, "garbage").unwrap();
1310 let past = std::time::SystemTime::now() - std::time::Duration::from_secs(7200);
1311 let times = std::fs::FileTimes::new().set_modified(past);
1312 let f = std::fs::OpenOptions::new()
1313 .write(true)
1314 .open(&garbage)
1315 .unwrap();
1316 f.set_times(times).unwrap();
1317
1318 let mut buckets = DoctorBuckets::default();
1319 run_stale_cache_pass(cache_dir, &mut buckets).expect("must not error");
1320 assert!(
1321 buckets.stale_cache.is_empty(),
1322 "non-json files must be ignored"
1323 );
1324 }
1325
1326 #[test]
1331 fn classify_installed_missing_meta_when_init_lua_lacks_meta() {
1332 let tmp = tempfile::tempdir().unwrap();
1333 let pkg_dir = tmp.path();
1334 let dest = pkg_dir.join("mypkg");
1335 std::fs::create_dir(&dest).unwrap();
1336 std::fs::write(dest.join("init.lua"), "local M = {} return M").unwrap();
1338
1339 let outcome =
1340 classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
1341 match outcome {
1342 DoctorOutcome::MissingMeta { reason, suggestion } => {
1343 assert!(reason.contains("lacks M.meta.name"), "reason: {reason}");
1344 assert!(
1345 suggestion.contains("alc_pkg_install"),
1346 "suggestion: {suggestion}"
1347 );
1348 assert!(
1349 suggestion.contains("mypkg"),
1350 "suggestion carries name: {suggestion}"
1351 );
1352 }
1353 _ => panic!("expected MissingMeta, got {outcome:?}"),
1354 }
1355 }
1356
1357 #[test]
1361 fn classify_installed_missing_meta_when_init_lua_has_empty_meta_name() {
1362 let tmp = tempfile::tempdir().unwrap();
1363 let pkg_dir = tmp.path();
1364 let dest = pkg_dir.join("mypkg");
1365 std::fs::create_dir(&dest).unwrap();
1366 std::fs::write(
1368 dest.join("init.lua"),
1369 "local M = {} M.meta = { name = \"\", version = \"0.1.0\" } return M",
1370 )
1371 .unwrap();
1372
1373 let outcome =
1374 classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
1375 assert!(
1376 matches!(outcome, DoctorOutcome::MissingMeta { .. }),
1377 "expected MissingMeta for empty name, got {outcome:?}"
1378 );
1379 }
1380
1381 #[test]
1384 fn classify_installed_no_missing_meta_when_init_lua_complete() {
1385 let tmp = tempfile::tempdir().unwrap();
1386 let pkg_dir = tmp.path();
1387 let dest = pkg_dir.join("mypkg");
1388 std::fs::create_dir(&dest).unwrap();
1389 std::fs::write(
1390 dest.join("init.lua"),
1391 "local M = {} M.meta = { name = \"mypkg\", version = \"0.1.0\" } return M",
1392 )
1393 .unwrap();
1394
1395 let outcome =
1396 classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
1397 assert!(
1398 matches!(outcome, DoctorOutcome::Healthy),
1399 "expected Healthy for complete init.lua with explicit type, got {outcome:?}"
1400 );
1401 }
1402
1403 #[test]
1408 fn run_hub_index_pass_emits_when_2_plus_pkgs_and_index_absent() {
1409 let tmp = tempfile::tempdir().unwrap();
1410 let root = tmp.path();
1411 for name in &["pkg_a", "pkg_b"] {
1413 let dir = root.join(name);
1414 std::fs::create_dir(&dir).unwrap();
1415 std::fs::write(dir.join("init.lua"), "return {}").unwrap();
1416 }
1417 let mut buckets = DoctorBuckets::default();
1420 run_hub_index_pass(root, &mut buckets).expect("run_hub_index_pass must not error");
1421
1422 assert_eq!(
1423 buckets.missing_hub_index.len(),
1424 1,
1425 "expected 1 missing_hub_index entry: {:?}",
1426 buckets.missing_hub_index
1427 );
1428 let entry = &buckets.missing_hub_index[0];
1429 assert_eq!(entry["kind"], "missing_hub_index");
1430 assert_eq!(entry["pkg_count"], 2);
1431 assert!(
1432 entry["suggestion"]
1433 .as_str()
1434 .unwrap_or("")
1435 .contains("alc_hub_reindex"),
1436 "suggestion: {entry}"
1437 );
1438 }
1439
1440 #[test]
1443 fn run_hub_index_pass_skips_when_only_1_pkg_dir() {
1444 let tmp = tempfile::tempdir().unwrap();
1445 let root = tmp.path();
1446 let dir = root.join("pkg_a");
1447 std::fs::create_dir(&dir).unwrap();
1448 std::fs::write(dir.join("init.lua"), "return {}").unwrap();
1449 let mut buckets = DoctorBuckets::default();
1452 run_hub_index_pass(root, &mut buckets).expect("run_hub_index_pass must not error");
1453
1454 assert!(
1455 buckets.missing_hub_index.is_empty(),
1456 "must not emit with only 1 pkg dir: {:?}",
1457 buckets.missing_hub_index
1458 );
1459 }
1460
1461 #[test]
1464 fn run_hub_index_pass_skips_when_hub_index_exists() {
1465 let tmp = tempfile::tempdir().unwrap();
1466 let root = tmp.path();
1467 for name in &["pkg_a", "pkg_b"] {
1468 let dir = root.join(name);
1469 std::fs::create_dir(&dir).unwrap();
1470 std::fs::write(dir.join("init.lua"), "return {}").unwrap();
1471 }
1472 std::fs::write(root.join("hub_index.json"), "{}").unwrap();
1474
1475 let mut buckets = DoctorBuckets::default();
1476 run_hub_index_pass(root, &mut buckets).expect("run_hub_index_pass must not error");
1477
1478 assert!(
1479 buckets.missing_hub_index.is_empty(),
1480 "must not emit when hub_index.json exists: {:?}",
1481 buckets.missing_hub_index
1482 );
1483 }
1484
1485 #[test]
1486 fn installed_missing_suggestion_shape() {
1487 let git = PackageSource::Git {
1488 url: "github.com/foo/bar".to_string(),
1489 rev: None,
1490 };
1491 let s = installed_missing_suggestion("ucb", &git);
1492 assert!(s.contains("alc_pkg_install"), "{s}");
1493 assert!(s.contains("\"ucb\""), "{s}");
1494 assert!(s.contains("github.com/foo/bar"), "{s}");
1495 }
1496
1497 #[test]
1502 fn installed_missing_suggestion_routes_bundled_to_alc_init() {
1503 let bundled = PackageSource::Bundled { collection: None };
1504 let s = installed_missing_suggestion("ucb", &bundled);
1505 assert!(s.contains("alc_init"), "bundled must suggest alc_init: {s}");
1506 assert!(
1507 !s.contains("alc_pkg_install"),
1508 "bundled must NOT suggest alc_pkg_install: {s}"
1509 );
1510 }
1511
1512 #[test]
1519 fn installed_missing_suggestion_routes_absolute_path_to_pkg_install() {
1520 let local = PackageSource::Path {
1521 path: "/abs/path/to/src".to_string(),
1522 };
1523 let s = installed_missing_suggestion("local_pkg", &local);
1524 assert!(s.contains("alc_pkg_install"), "{s}");
1525 assert!(s.contains("/abs/path/to/src"), "{s}");
1526 }
1527
1528 #[test]
1532 fn installed_missing_suggestion_routes_unknown_to_reindex() {
1533 let s = installed_missing_suggestion("legacy_pkg", &PackageSource::Unknown);
1534 assert!(
1535 s.contains("alc_hub_reindex"),
1536 "Unknown must suggest alc_hub_reindex: {s}"
1537 );
1538 }
1539
1540 #[test]
1543 fn extract_subs_double_quote() {
1544 let src = r#"
1545local M = {}
1546local check = require("mypkg.check")
1547local t = require("mypkg.t")
1548return M
1549"#;
1550 let subs = extract_required_subs(src, "mypkg");
1551 assert_eq!(subs, vec!["check", "t"]);
1552 }
1553
1554 #[test]
1555 fn extract_subs_single_quote() {
1556 let src = "local x = require('mypkg.sub')";
1557 let subs = extract_required_subs(src, "mypkg");
1558 assert_eq!(subs, vec!["sub"]);
1559 }
1560
1561 #[test]
1562 fn extract_subs_ignores_other_packages() {
1563 let src = r#"
1564local x = require("other.sub")
1565local y = require("mypkg.mine")
1566"#;
1567 let subs = extract_required_subs(src, "mypkg");
1568 assert_eq!(subs, vec!["mine"]);
1569 }
1570
1571 #[test]
1572 fn extract_subs_deduplicates() {
1573 let src = r#"
1574local a = require("mypkg.check")
1575local b = require("mypkg.check")
1576"#;
1577 let subs = extract_required_subs(src, "mypkg");
1578 assert_eq!(subs, vec!["check"]);
1579 }
1580
1581 #[test]
1582 fn extract_subs_ignores_dynamic_require() {
1583 let src = r#"local x = require(mod_name)"#;
1585 let subs = extract_required_subs(src, "mypkg");
1586 assert!(subs.is_empty(), "dynamic require must be ignored: {subs:?}");
1587 }
1588
1589 #[test]
1590 fn extract_subs_ignores_nested_dots() {
1591 let src = r#"local x = require("mypkg.sub.deeper")"#;
1593 let subs = extract_required_subs(src, "mypkg");
1594 assert!(
1595 subs.is_empty(),
1596 "nested dotted require must be ignored: {subs:?}"
1597 );
1598 }
1599
1600 #[test]
1601 fn extract_subs_empty_for_no_require() {
1602 let src = r#"local M = {} return M"#;
1603 let subs = extract_required_subs(src, "mypkg");
1604 assert!(subs.is_empty());
1605 }
1606
1607 #[test]
1610 fn check_incomplete_returns_none_when_all_subs_present_as_lua() {
1611 let tmp = tempfile::tempdir().unwrap();
1612 let dest = tmp.path().join("mypkg");
1613 std::fs::create_dir(&dest).unwrap();
1614 std::fs::write(
1615 dest.join("init.lua"),
1616 r#"local c = require("mypkg.check") return {}"#,
1617 )
1618 .unwrap();
1619 std::fs::write(dest.join("check.lua"), "return {}").unwrap();
1620
1621 assert!(check_incomplete("mypkg", &dest, false).is_none());
1622 }
1623
1624 #[test]
1625 fn check_incomplete_returns_none_when_sub_is_dir_init() {
1626 let tmp = tempfile::tempdir().unwrap();
1627 let dest = tmp.path().join("mypkg");
1628 std::fs::create_dir(&dest).unwrap();
1629 std::fs::write(
1630 dest.join("init.lua"),
1631 r#"local c = require("mypkg.sub") return {}"#,
1632 )
1633 .unwrap();
1634 std::fs::create_dir(dest.join("sub")).unwrap();
1636 std::fs::write(dest.join("sub").join("init.lua"), "return {}").unwrap();
1637
1638 assert!(check_incomplete("mypkg", &dest, false).is_none());
1639 }
1640
1641 #[test]
1642 fn check_incomplete_detects_missing_sub() {
1643 let tmp = tempfile::tempdir().unwrap();
1644 let dest = tmp.path().join("mypkg");
1645 std::fs::create_dir(&dest).unwrap();
1646 std::fs::write(
1647 dest.join("init.lua"),
1648 r#"
1649local check = require("mypkg.check")
1650local t = require("mypkg.t")
1651return {}
1652"#,
1653 )
1654 .unwrap();
1655 std::fs::write(dest.join("check.lua"), "return {}").unwrap();
1657
1658 let outcome = check_incomplete("mypkg", &dest, false).expect("should detect incomplete");
1659 match outcome {
1660 DoctorOutcome::IncompletePkg {
1661 missing_subs,
1662 suggestion,
1663 } => {
1664 assert_eq!(missing_subs, vec!["t"], "missing_subs: {missing_subs:?}");
1665 assert!(
1666 suggestion.contains("alc_pkg_install"),
1667 "non-symlink suggestion: {suggestion}"
1668 );
1669 }
1670 _ => panic!("expected IncompletePkg"),
1671 }
1672 }
1673
1674 #[test]
1675 fn check_incomplete_suggestion_uses_link_for_symlink() {
1676 let tmp = tempfile::tempdir().unwrap();
1677 let dest = tmp.path().join("mypkg");
1678 std::fs::create_dir(&dest).unwrap();
1679 std::fs::write(
1680 dest.join("init.lua"),
1681 r#"local x = require("mypkg.missing") return {}"#,
1682 )
1683 .unwrap();
1684 let outcome = check_incomplete("mypkg", &dest, true).expect("should detect incomplete");
1687 match outcome {
1688 DoctorOutcome::IncompletePkg { suggestion, .. } => {
1689 assert!(
1690 suggestion.contains("alc_pkg_link"),
1691 "symlink suggestion: {suggestion}"
1692 );
1693 }
1694 _ => panic!("expected IncompletePkg"),
1695 }
1696 }
1697
1698 #[test]
1699 fn check_incomplete_returns_none_when_no_init_lua() {
1700 let tmp = tempfile::tempdir().unwrap();
1702 let dest = tmp.path().join("mypkg");
1703 std::fs::create_dir(&dest).unwrap();
1704
1705 assert!(check_incomplete("mypkg", &dest, false).is_none());
1706 }
1707
1708 #[test]
1709 fn classify_installed_incomplete_pkg() {
1710 let tmp = tempfile::tempdir().unwrap();
1712 let pkg_dir = tmp.path();
1713 let dest = pkg_dir.join("mypkg");
1714 std::fs::create_dir(&dest).unwrap();
1715 std::fs::write(
1716 dest.join("init.lua"),
1717 r#"local x = require("mypkg.sub") return {}"#,
1718 )
1719 .unwrap();
1720 let outcome =
1723 classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
1724 match outcome {
1725 DoctorOutcome::IncompletePkg {
1726 missing_subs,
1727 suggestion,
1728 } => {
1729 assert_eq!(missing_subs, vec!["sub"]);
1730 assert!(suggestion.contains("alc_pkg_install"), "{suggestion}");
1731 }
1732 _ => panic!("expected IncompletePkg, got {outcome:?}"),
1733 }
1734 }
1735
1736 #[test]
1737 fn classify_installed_healthy_when_all_subs_present() {
1738 let tmp = tempfile::tempdir().unwrap();
1741 let pkg_dir = tmp.path();
1742 let dest = pkg_dir.join("mypkg");
1743 std::fs::create_dir(&dest).unwrap();
1744 std::fs::write(
1745 dest.join("init.lua"),
1746 "local M = {}\n\
1747 M.meta = { name = \"mypkg\", version = \"0.1.0\" }\n\
1748 local x = require(\"mypkg.sub\")\n\
1749 return M",
1750 )
1751 .unwrap();
1752 std::fs::write(dest.join("sub.lua"), "return {}").unwrap();
1753
1754 let outcome =
1755 classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
1756 assert!(
1757 matches!(outcome, DoctorOutcome::Healthy),
1758 "expected Healthy, got {outcome:?}"
1759 );
1760 }
1761
1762 #[cfg(unix)]
1769 #[tokio::test]
1770 async fn run_alive_unregistered_symlink_pass_unregistered_pkg() {
1771 let tmp = tempfile::tempdir().unwrap();
1772 let root = tmp.path().to_path_buf();
1773
1774 let pkg_dir = root.join("packages");
1776 std::fs::create_dir_all(&pkg_dir).unwrap();
1777
1778 let real = root.join("real_pkg");
1780 std::fs::create_dir(&real).unwrap();
1781 std::fs::write(
1782 real.join("init.lua"),
1783 "local M = {}\n\
1784 M.meta = { name = \"mypkg\", version = \"0.1.0\" }\n\
1785 return M",
1786 )
1787 .unwrap();
1788
1789 let link = pkg_dir.join("mypkg");
1791 std::os::unix::fs::symlink(&real, &link).unwrap();
1792
1793 let app_service = make_app_service_at(root).await;
1794 let registered = HashSet::new();
1795 let registered_paths: Vec<PathBuf> = vec![];
1796 let mut buckets = DoctorBuckets::default();
1797 app_service
1798 .run_alive_unregistered_symlink_pass(
1799 &pkg_dir,
1800 ®istered,
1801 ®istered_paths,
1802 None,
1803 &mut buckets,
1804 )
1805 .await
1806 .expect("pass ok");
1807
1808 assert_eq!(
1809 buckets.unregistered_pkg.len(),
1810 1,
1811 "expected 1 unregistered_pkg entry, got {:?}",
1812 buckets.unregistered_pkg
1813 );
1814 let entry = &buckets.unregistered_pkg[0];
1815 assert_eq!(entry["name"], "mypkg");
1816 assert_eq!(entry["kind"], "unregistered_pkg");
1817 assert_eq!(entry["source"], "unknown");
1818 assert!(
1819 entry["reason"]
1820 .as_str()
1821 .unwrap_or("")
1822 .contains("alive symlink"),
1823 "reason must mention alive symlink: {:?}",
1824 entry["reason"]
1825 );
1826 let suggestion = entry["suggestion"].as_array().expect("suggestion is array");
1827 assert_eq!(suggestion.len(), 4, "suggestion must have 4 elements");
1828 }
1829}