Skip to main content

algocline_app/service/pkg/
doctor.rs

1//! `pkg_doctor` — read-only diagnosis for package state (Wave 2 of local-first DX).
2//!
3//! The actuator counterpart is [`super::repair`] (`pkg_repair`). `pkg_doctor`
4//! classifies packages into ten buckets without touching the filesystem:
5//!
6//! | Bucket              | Source-of-truth                                         | Condition                                          |
7//! |---------------------|---------------------------------------------------------|----------------------------------------------------|
8//! | `healthy`           | `installed.json` + `~/.algocline/packages/{name}`       | dest directory exists (resolved through symlinks)  |
9//! | `installed_missing` | `installed.json`                                        | dest missing (non-symlink), `pkg_install` can heal |
10//! | `symlink_dangling`  | `installed.json` (manifest-pass) + filesystem scan      | dest is a symlink whose target is missing          |
11//! | `stale_cache`       | `~/.algocline/hub_cache/{hash}.json`                    | mtime older than 3600s (CACHE_TTL_SECS); refresh   |
12//! |                     |                                                         | via alc_hub_search                                 |
13//! | `path_missing`      | `alc.toml` / `alc.local.toml`                           | declared `path = ...` does not exist               |
14//! | `incomplete_pkg`    | `installed.json` + `{pkg_dir}/{name}/init.lua`          | init.lua requires sibling sub (`pkg.sub`) but      |
15//! |                     |                                                         | `sub.lua` / `sub/init.lua` is missing              |
16//! | `missing_meta`      | `{pkg_dir}/{name}/init.lua`                             | init.lua absent or does not declare `M.meta.name`  |
17//! | `missing_hub_index` | `{project_root}/hub_index.json`                         | collection root has 2+ pkg dirs but no index file  |
18//! | `spec_missing`      | `{pkg_dir}/{name}/spec/`                                | spec/ exists but contains zero `*_spec.lua` files  |
19//! | `unregistered_pkg`  | `~/.algocline/packages/{name}/init.lua`                 | physical dir with init.lua but not registered in   |
20//! |                     |                                                         | installed.json / alc.toml / alc.local.toml         |
21//!
22//! Contract:
23//! - **No side effects.** No `fs::write`, `fs::remove_*`, `fs::create_*`,
24//!   symlink operations, or `pkg_install`. Filesystem is read-only.
25//! - Reuses [`super::repair`]'s `pub(super)` helpers to keep the classification
26//!   logic authoritative in one place (symlink-dangling suggestion wording and
27//!   the path-missing scan in particular).
28//!
29//! The JSON output schema always contains ten top-level buckets:
30//! `healthy`, `incomplete_pkg`, `installed_missing`, `missing_hub_index`,
31//! `missing_meta`, `path_missing`, `spec_missing`, `stale_cache`,
32//! `symlink_dangling`, `unregistered_pkg`. The `narrative_issues` bucket was removed in
33//! #1778221491-39903 (narrative SSOT decommission).
34//! Key order within the serialized string follows `serde_json`'s default
35//! (alphabetical when `preserve_order` is off, as it is
36//! in this workspace) — the contract is "these ten keys always present", not
37//! textual ordering.
38//!
39//! ## `incomplete_pkg` detection
40//!
41//! Only **static string-literal `require`** calls of the form
42//! `require("pkg_name.sub")` or `require('pkg_name.sub')` are scanned.
43//! Dynamic require forms (`require(variable)`) and non-quoted forms
44//! (`require "foo.bar"`) are **not** detected (MVP scope — false negatives
45//! are acceptable; false positives are not). A future version may use mlua
46//! to perform a real module resolution dry-run.
47
48use 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
64/// Suggestion text emitted for packages whose `type_source` is
65/// `AutoDetectedLibrary`. Shared between [`DoctorOutcome::UnmarkedLibrary`]
66/// (doctor pass) and [`super::list`] (pkg_list warnings field) to keep the
67/// user-facing message consistent from a single source.
68pub(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
73/// Same TTL discipline as `hub::CACHE_TTL_SECS` (hub.rs:96).
74/// Both consts must be kept in sync; if you change one, change the other.
75/// (hub's const is module-private, so we redefine here instead of importing.)
76const DOCTOR_CACHE_TTL_SECS: u64 = 3600;
77
78/// Classification of a single manifest-tracked package (read-only).
79#[derive(Debug)]
80enum DoctorOutcome {
81    /// Destination exists and is reachable — no action required.
82    Healthy,
83    /// Destination is a symlink whose target is missing.
84    SymlinkDangling { reason: String, suggestion: String },
85    /// Destination is missing (non-symlink). Install can heal from `source`.
86    InstalledMissing { reason: String, suggestion: String },
87    /// Package directory exists but one or more submodule files required by
88    /// `init.lua` are missing.
89    IncompletePkg {
90        missing_subs: Vec<String>,
91        suggestion: String,
92    },
93    /// Package directory exists but `init.lua` does not declare a valid
94    /// `M.meta.name` field (parse failure, missing field, or absent file).
95    MissingMeta { reason: String, suggestion: String },
96    /// Package directory exists and has a `spec/` directory, but contains
97    /// zero `*_spec.lua` files (opt-in: pkgs without `spec/` are skipped).
98    SpecMissing { reason: String, suggestion: String },
99    /// Package directory exists and `init.lua` is valid, but `type_source`
100    /// is `AutoDetectedLibrary` — the package was auto-classified as a library
101    /// rather than having an explicit `M.meta.type = "library"` declaration.
102    UnmarkedLibrary { suggestion: String },
103}
104
105/// Accumulator for the JSON output buckets.
106#[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    /// Physical directories under `~/.algocline/packages/` that contain
118    /// `init.lua` but are not registered in any of the three authoritative
119    /// sources (installed.json / alc.toml / alc.local.toml).
120    ///
121    /// Unlike all other buckets, entries here carry `suggestion: array<string>`
122    /// (Clippy-style multi-line) instead of `suggestion: string`.
123    unregistered_pkg: Vec<serde_json::Value>,
124    /// Packages whose `type_source` is `AutoDetectedLibrary` — they were
125    /// classified as a library by auto-detection but lack an explicit
126    /// `M.meta.type = "library"` declaration. A suggestion is emitted to
127    /// encourage explicit declaration for clarity and tooling.
128    ///
129    /// Crux constraint: only fires when `type_source == AutoDetectedLibrary`;
130    /// `None` (legacy / missing field) never triggers this bucket.
131    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        // All buckets are always emitted (empty arrays when no entries).
151        // `serde_json::json!` serializes keys alphabetically without the
152        // `preserve_order` feature — consumers parse as a Map, not by order.
153        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
170/// Parse static string-literal `require` calls from Lua source and return the
171/// list of submodule names that belong to `pkg_name`.
172///
173/// Recognized pattern (parenthesised string literal, single or double quote):
174/// ```text
175/// require("pkg_name.sub")
176/// require('pkg_name.sub')
177/// ```
178///
179/// **Not** recognized (MVP scope — false negatives are acceptable):
180/// - `require "foo.bar"` (no parentheses)
181/// - `require(variable)` (dynamic)
182/// - `require([[foo.bar]])` (long-string literal)
183fn 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        // Skip whitespace after `require`.
192        let trimmed = remaining.trim_start_matches([' ', '\t']);
193
194        // Must be followed by `(`.
195        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        // Must be followed by a string quote.
202        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                // Only direct children: `pkg.sub`, not `pkg.sub.deeper`.
216                subs.push(sub.to_string());
217            }
218        }
219    }
220
221    subs.sort();
222    subs.dedup();
223    subs
224}
225
226/// Build the suggestion string for an `incomplete_pkg` entry, branched by
227/// whether the package came from a symlink (link path) or an installed copy.
228fn 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
238/// Suggestion string for `installed_missing`, branched by source kind to
239/// mirror `pkg_repair`'s routing:
240///
241/// - `Git` → `alc_pkg_install(<url>)`
242/// - `Path` → `alc_pkg_install(<path>)` (local re-copy)
243/// - `Bundled` → `alc_init` (bundled packages cannot be reinstalled via
244///   `alc_pkg_install`; they ship inside the algocline binary)
245/// - `Installed` → legacy marker with no re-fetch info (user must re-record
246///   source via `alc_pkg_install`)
247/// - `Unknown` → reindex + reinstall (pre-typed manifest with no source)
248fn 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
274/// Push a manifest-pass outcome into the appropriate bucket.
275fn 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
331/// Check whether the package directory at `dest` is incomplete: read
332/// `init.lua`, extract static `require("pkg.sub")` calls, and verify that
333/// each referenced sub-file exists as `{dest}/{sub}.lua` or
334/// `{dest}/{sub}/init.lua`.
335///
336/// Returns `Some(DoctorOutcome::IncompletePkg { .. })` when one or more
337/// submodule files are missing, `None` when everything is present or when
338/// `init.lua` cannot be read (IO errors are logged as warnings and treated as
339/// "no incomplete evidence" rather than propagated — the directory-level
340/// `Healthy` classification already passed and the init.lua read is
341/// best-effort).
342fn 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
380/// Check whether the package directory at `dest` has a `spec/` directory
381/// that exists but contains zero `*_spec.lua` files. Narrow scope (opt-in):
382/// packages without a `spec/` directory return `Ok(None)` silently.
383///
384/// Aligns with `alc_pkg_test`'s spec discovery convention
385/// (`<pkg_root>/spec/*_spec.lua`).
386///
387/// Returns `Err(String)` when `fs::read_dir` / `DirEntry::file_type` fails
388/// (judgment-critical fs ops; silent drop would produce false negatives).
389fn 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
435/// Classify a manifest entry by inspecting only the destination directory.
436/// Mirrors the pre-install branch of [`super::repair::repair_installed`] but
437/// never attempts an install.
438///
439/// After confirming the package directory is reachable, performs three
440/// sequential best-effort checks: missing submodule files, missing meta,
441/// and missing spec. A final check detects auto-classified library packages
442/// that lack an explicit `M.meta.type = "library"` declaration
443/// ([`DoctorOutcome::UnmarkedLibrary`]).
444///
445/// `parse_from_init_lua` is called once and reused across all meta-dependent
446/// checks to avoid redundant I/O.
447///
448/// # Crux constraint
449///
450/// The `UnmarkedLibrary` outcome fires **only** when
451/// `entity.type_source == Some(TypeSource::AutoDetectedLibrary)`.
452/// Packages with `type_source == None` (legacy / absent field) are never
453/// flagged to avoid false-positive warnings on pre-existing entries.
454fn 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        // `try_exists` follows the symlink — true iff target is alive.
467        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        // Symlink alive — parse init.lua once, then run all sequential checks.
488        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        // entity is Some here (is_none() returned early above).
505        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        // Directory exists — parse init.lua once, then run all sequential checks.
520        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        // entity is Some here (is_none() returned early above).
537        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
556/// Classify every manifest entry into the four buckets. When `target_filter`
557/// is `Some(name)`, look the entry up directly (O(log N) on BTreeMap) instead
558/// of scanning the full map.
559fn 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
579/// Drain the unattached-symlink scan results into the `symlink_dangling`
580/// bucket. The shared helper writes tagged entries into a scratch vec so
581/// its signature can stay aligned with `pkg_repair`'s unrepairable bucket.
582fn 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
593/// Scan `alc.toml` + `alc.local.toml` for declared paths that no longer
594/// resolve. `resolved_root = None` means no project context was located,
595/// which mirrors `pkg_repair`'s skip-on-missing behavior.
596fn 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
622/// Scan the collection project root for a missing `hub_index.json`.
623///
624/// Fires when **all three** conditions hold:
625/// 1. Called only when `target_filter` is `None` and `resolved_root` is `Some`
626///    (enforced by the caller in [`AppService::pkg_doctor`]).
627/// 2. Two or more direct subdirectories of `root` each contain an `init.lua`
628///    file (collection-repo heuristic — single-pkg repos have fewer than 2).
629/// 3. `{root}/hub_index.json` does not exist.
630///
631/// All `fs` errors are propagated through `?` to the MCP wire layer; none are
632/// silently swallowed.
633///
634/// # Errors
635///
636/// Returns `Err(String)` when `fs::read_dir`, `DirEntry::file_type`, or
637/// `Path::try_exists` fails. The error message carries enough context to
638/// identify which path triggered the failure.
639fn 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
694/// Scan `cache_dir` (= `~/.algocline/hub_cache/`) for `*.json` files whose
695/// mtime is older than `DOCTOR_CACHE_TTL_SECS` (3600s) and emit one
696/// `stale_cache` entry per stale file. Skips when `cache_dir` does not
697/// exist (cache miss = first run = normal, not an error).
698///
699/// `mtime` retrieval failures (`metadata.modified()` / `.elapsed()`) are
700/// treated as "judgment impossible → safe-side fresh" and the file is
701/// skipped, *not* silently dropped. This is an explicit cross-platform
702/// fallback (older filesystems may not support `modified()`, and a system
703/// clock that drifted earlier than the file's mtime produces
704/// `SystemTimeError` from `.elapsed()`).
705///
706/// Returns `Err(String)` when `try_exists` / `read_dir` / `file_type` /
707/// `metadata` fail (these are judgment-critical fs ops; silent drop would
708/// produce false negatives).
709fn 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        // mtime retrieval — `.ok()` here is intentional cross-platform
748        // fallback (see fn doc), not a silent error drop.
749        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
770/// Walk `pkg_dir` for physical directories with `init.lua` that are not
771/// registered in any manifest source, and push them into
772/// `buckets.unregistered_pkg`.
773///
774/// # Arguments
775///
776/// * `pkg_dir` — `~/.algocline/packages/`
777/// * `registered` — set of package names from all three registration sources
778/// * `registered_paths` — canonicalized `path` entries from alc.toml /
779///   alc.local.toml; dirs whose canonical path matches are skipped to avoid
780///   false positives (crux constraint: canonical path comparison)
781/// * `target_filter` — when `Some(name)`, restrict to that single package
782/// * `buckets` — accumulator; entries are pushed into `buckets.unregistered_pkg`
783///
784/// # Errors
785///
786/// Propagates `Err(String)` from `collect_unregistered_pkg_dirs` when
787/// `pkg_dir` exists but cannot be read.
788fn 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    /// Walk `pkg_dir` for **alive symlinks** (target exists, `init.lua` present) that
803    /// are not registered in any authoritative source, then push entries into
804    /// `buckets.unmarked_library` (when `type_source` is `AutoDetectedLibrary`) or
805    /// `buckets.unregistered_pkg` (all other cases, including parse failures).
806    ///
807    /// # Arguments
808    ///
809    /// * `pkg_dir` — `~/.algocline/packages/` root
810    /// * `registered` — set of package names from installed.json + alc.toml + alc.local.toml
811    /// * `registered_paths` — canonicalized paths from path-dep entries to suppress
812    ///   false positives (crux constraint: canonical path comparison)
813    /// * `target_filter` — when `Some(name)`, restrict to that single package
814    /// * `buckets` — accumulator; entries are pushed into `buckets.unmarked_library`
815    ///   or `buckets.unregistered_pkg` depending on `type_source`
816    ///
817    /// # Returns
818    ///
819    /// `Ok(())` after all alive symlinks have been classified and appended to
820    /// the appropriate bucket.
821    ///
822    /// # Errors
823    ///
824    /// Propagates `Err(String)` from `collect_alive_unregistered_symlinks` when
825    /// `pkg_dir` exists but cannot be read. Per-package eval failures are logged
826    /// via `tracing::warn!` and fall through to `Unregistered` (no propagation).
827    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    /// Diagnose package state without any side effects. Returns a JSON string
887    /// with ten arrays (`healthy`, `incomplete_pkg`, `installed_missing`,
888    /// `missing_hub_index`, `missing_meta`, `path_missing`, `spec_missing`,
889    /// `stale_cache`, `symlink_dangling`, `unregistered_pkg`).
890    ///
891    /// `name` restricts the report to a single package; `None` inspects every
892    /// known package. `project_root` is only consulted for the
893    /// `alc.toml` / `alc.local.toml` pass and the `missing_hub_index` scan.
894    /// Falls back to ancestor walk from cwd when `None`.
895    ///
896    /// Error surface matches `pkg_repair`:
897    /// - `load_manifest()` / `packages_dir()` failures propagate via `?`.
898    /// - Per-entry `fs::read_dir` errors inside the unattached-symlink scan
899    ///   are logged via `tracing::warn!` and skipped (helper's behavior).
900    /// - `init.lua` read errors during the incomplete check are logged via
901    ///   `tracing::warn!` and skipped (best-effort, no propagation).
902    /// - `fs::read_dir` / `try_exists` errors inside `run_hub_index_pass`
903    ///   propagate via `?` (collection-root scan requires reliable fs access).
904    /// - `try_exists` / `read_dir` / `file_type` / `metadata` errors inside
905    ///   `run_stale_cache_pass` propagate via `?`. `metadata.modified()` and
906    ///   `.elapsed()` failures (cross-platform fallback) skip the file.
907    /// - When `name = Some(target)` and every bucket ends empty, returns
908    ///   `Err` with the same wording used by `pkg_repair`.
909    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        // Build the set of registered package names from all three sources:
921        // installed.json (manifest), alc.toml [packages], alc.local.toml [packages].
922        // Also collect canonicalized paths from path-dep entries to avoid false
923        // positives in unregistered_pkg detection (crux constraint).
924        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            // alc.toml
929            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                                // Path entry in alc.toml may point to a
943                                // non-existent dir (caught by path_missing pass).
944                                // Canonicalize failure here is expected; skip
945                                // with a warn to avoid false positives being
946                                // silently missed.
947                                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            // alc.local.toml
959            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            &registered,
992            &registered_paths,
993            target_filter,
994            &mut buckets,
995        )?;
996        self.run_alive_unregistered_symlink_pass(
997            &pkg_dir,
998            &registered,
999            &registered_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    /// Build a minimal `ManifestEntry` with a `PackageSource::Path`.
1031    /// Takes a legacy path string so the existing tests keep reading
1032    /// naturally; the arg is wrapped into the typed `Path` variant.
1033    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        // init.lua with valid M.meta and explicit type so UnmarkedLibrary does not fire.
1052        // Explicit `type = "library"` is required since ST3; packages without it are
1053        // classified as UnmarkedLibrary rather than Healthy.
1054        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        // init.lua with valid M.meta and explicit type so UnmarkedLibrary does not fire.
1118        // Explicit `type = "library"` is required since ST3.
1119        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        // NOTE: `serde_json` without the `preserve_order` feature emits JSON
1136        // object keys in alphabetical order, matching `pkg_repair`'s actual
1137        // behavior. The spec's "fixed order" requirement is satisfied by
1138        // always emitting these eleven top-level keys; consumers parse as a
1139        // Map rather than relying on textual key order.
1140        //
1141        // Note: `narrative_issues` bucket removed in #1778221491-39903.
1142        // Note: `unregistered_pkg` bucket added (physical dir without manifest entry).
1143        // Note: `unmarked_library` bucket added (auto-detected library without explicit type).
1144        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        // suggestion must be an array (crux constraint: array<string> for unregistered_pkg).
1203        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        // suggestion must be a string.
1210        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    // ── spec_missing ─────────────────────────────────────────────────────
1261
1262    /// T1: pkg has spec/foo_spec.lua → check_spec_missing returns Ok(None).
1263    #[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    /// T2: pkg has empty spec/ → SpecMissing emitted with reason+suggestion.
1274    #[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    /// T3: pkg has spec/ with only non-spec files → SpecMissing emitted.
1292    #[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    /// T4: pkg has no spec/ → Ok(None) (silent skip, opt-in scope).
1306    #[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    // ── stale_cache ──────────────────────────────────────────────────────
1319
1320    #[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    // ── missing_meta ─────────────────────────────────────────────────────
1397
1398    /// T1 (happy path): a package directory whose init.lua omits `M.meta`
1399    /// entirely is classified as `MissingMeta`.
1400    #[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        // init.lua present but declares no M.meta block.
1407        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    /// T2 (edge case): `M.meta = {{ name = "" }}` — empty name string —
1428    /// is treated as missing meta (parse_from_init_lua returns None for empty
1429    /// name via option_from_str).
1430    #[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        // M.meta with an empty name string.
1437        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    /// T3 (no false positive): a complete init.lua with a valid `M.meta.name`
1452    /// and explicit `M.meta.type` must not trigger `MissingMeta` or `UnmarkedLibrary`.
1453    /// Since ST3, packages without explicit type are classified as `UnmarkedLibrary`
1454    /// rather than `Healthy`; the fixture must declare `type = "library"` explicitly.
1455    #[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    // ── run_hub_index_pass ────────────────────────────────────────────────
1476
1477    /// T1 (happy path): two subdirectories each containing init.lua, with no
1478    /// hub_index.json in the root — emits one `missing_hub_index` entry.
1479    #[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        // Two package dirs each with init.lua.
1484        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        // hub_index.json intentionally absent.
1490
1491        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    /// T2 (boundary): only one package dir with init.lua — heuristic requires
1513    /// 2+, so no entry is emitted.
1514    #[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        // No hub_index.json — but only 1 pkg dir, so must NOT fire.
1522
1523        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    /// T3 (error path guard): two package dirs but hub_index.json already
1534    /// exists — no entry is emitted.
1535    #[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        // hub_index.json present.
1545        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    /// A bundled-source entry must route the user to `alc_init`, NOT
1570    /// `alc_pkg_install("bundled")` (which would fail — bundled packages
1571    /// ship inside the algocline binary and are restored via `alc_init`).
1572    /// Mirrors `repair.rs` bundled arm.
1573    #[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    /// A `Path` source entry emits a suggestion pointing at
1585    /// `alc_pkg_install(<path>)` — matching repair's LocalPath installer
1586    /// route. (Under the typed migration, `alc_pkg_install` now records
1587    /// local installs as `Path { path }` rather than the legacy
1588    /// `Installed` coercion, so this is the canonical local-reinstall
1589    /// suggestion.)
1590    #[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    /// `Unknown` source (legacy pre-typed entry with no recorded source)
1601    /// must route the user to `alc_hub_reindex` before attempting a
1602    /// reinstall — mirrors the `Unrepairable` routing in `repair.rs`.
1603    #[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    // ── extract_required_subs ────────────────────────────────────────────
1613
1614    #[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        // Dynamic require (no parenthesised string literal) must not be detected.
1656        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        // Only direct children: `pkg.sub`, not `pkg.sub.deeper`.
1664        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    // ── check_incomplete ─────────────────────────────────────────────────
1680
1681    #[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        // sub/ directory with init.lua
1707        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        // only `check.lua` present, `t.lua` missing
1728        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        // `missing.lua` absent
1757
1758        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        // Package with no init.lua at all — best-effort skip, returns None.
1773        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        // classify_installed should return IncompletePkg when sub.lua is missing.
1783        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        // sub.lua intentionally absent
1793
1794        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        // classify_installed should return Healthy when all required subs exist,
1811        // M.meta is declared with explicit type in init.lua.
1812        // Since ST3, packages without explicit type are classified as UnmarkedLibrary;
1813        // the fixture declares `type = "library"` explicitly.
1814        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    // ── UnmarkedLibrary detection ─────────────────────────────────────────
1837
1838    /// T1 (property test): an auto-detected library package (no explicit
1839    /// `M.meta.type` in init.lua, no `M.run` function) is classified as
1840    /// `UnmarkedLibrary` with the standard suggestion string.
1841    ///
1842    /// Crux constraint: type_source == AutoDetectedLibrary triggers the warning.
1843    #[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        // init.lua: no M.meta.type, no M.run → parse_from_init_lua sets
1850        // type_source = AutoDetectedLibrary.
1851        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    /// T2 (boundary / crux constraint): a package with an explicit
1877    /// `M.meta.type = "library"` declaration (type_source = Explicit) must
1878    /// **not** trigger UnmarkedLibrary — it is classified as Healthy.
1879    #[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        // Explicit M.meta.type = "library" → parse_from_init_lua sets
1886        // type_source = Explicit (not AutoDetectedLibrary).
1887        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    /// T3 (boundary): a package with an `M.run` function (auto-detected
1904    /// runnable, type_source = AutoDetectedRunnable) must **not** trigger
1905    /// UnmarkedLibrary.
1906    #[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        // M.run present → parse_from_init_lua sets type_source = AutoDetectedRunnable.
1913        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    /// T4 (crux constraint — None legacy): a package whose init.lua lacks
1931    /// `M.meta` entirely (type_source = None) must **not** trigger
1932    /// UnmarkedLibrary. It is classified as MissingMeta instead.
1933    #[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        // init.lua has no M.meta → parse_from_init_lua returns None.
1940        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        // Must be MissingMeta, never UnmarkedLibrary — None type_source is excluded.
1945        assert!(
1946            matches!(outcome, DoctorOutcome::MissingMeta { .. }),
1947            "None type_source must not trigger UnmarkedLibrary; expected MissingMeta, got {outcome:?}"
1948        );
1949    }
1950
1951    // ── run_alive_unregistered_symlink_pass ───────────────────────────────
1952
1953    /// T-alive-1 (crux constraint §1 + §2): an alive symlink whose target
1954    /// contains an `init.lua` with no explicit `M.meta.type` (AutoDetectedLibrary)
1955    /// and is not registered in any source is pushed to `buckets.unmarked_library`.
1956    #[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        // fixture: root/packages/mylib → root/real_lib
1963        let pkg_dir = root.join("packages");
1964        std::fs::create_dir_all(&pkg_dir).unwrap();
1965
1966        // Real package directory (symlink target).
1967        let real = root.join("real_lib");
1968        std::fs::create_dir(&real).unwrap();
1969        // No M.meta.type, no M.run → eval_simple classifies as auto_detected_library.
1970        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        // Alive symlink: packages/mylib → real_lib.
1977        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                &registered,
1988                &registered_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    /// T-alive-2 (crux constraint §1 + §2): an alive symlink whose target
2016    /// contains an `init.lua` with explicit `M.meta.type = "library"` (not
2017    /// AutoDetectedLibrary) and is not registered is pushed to
2018    /// `buckets.unregistered_pkg`.
2019    #[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        // fixture: root/packages/myrunnable → root/real_runnable
2026        let pkg_dir = root.join("packages");
2027        std::fs::create_dir_all(&pkg_dir).unwrap();
2028
2029        // Real package directory (symlink target).
2030        let real = root.join("real_runnable");
2031        std::fs::create_dir(&real).unwrap();
2032        // M.run present → eval_simple classifies as auto_detected_runnable (not library).
2033        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        // Alive symlink: packages/myrunnable → real_runnable.
2043        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                &registered,
2054                &registered_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}