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 nine 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//!
20//! Contract:
21//! - **No side effects.** No `fs::write`, `fs::remove_*`, `fs::create_*`,
22//!   symlink operations, or `pkg_install`. Filesystem is read-only.
23//! - Reuses [`super::repair`]'s `pub(super)` helpers to keep the classification
24//!   logic authoritative in one place (symlink-dangling suggestion wording and
25//!   the path-missing scan in particular).
26//!
27//! The JSON output schema always contains nine top-level buckets:
28//! `healthy`, `incomplete_pkg`, `installed_missing`, `missing_hub_index`,
29//! `missing_meta`, `path_missing`, `spec_missing`, `stale_cache`,
30//! `symlink_dangling`. The `narrative_issues` bucket was removed in
31//! #1778221491-39903 (narrative SSOT decommission).
32//! Key order within the serialized string follows `serde_json`'s default
33//! (alphabetical when `preserve_order` is off, as it is
34//! in this workspace) — the contract is "these nine keys always present", not
35//! textual ordering.
36//!
37//! ## `incomplete_pkg` detection
38//!
39//! Only **static string-literal `require`** calls of the form
40//! `require("pkg_name.sub")` or `require('pkg_name.sub')` are scanned.
41//! Dynamic require forms (`require(variable)`) and non-quoted forms
42//! (`require "foo.bar"`) are **not** detected (MVP scope — false negatives
43//! are acceptable; false positives are not). A future version may use mlua
44//! to perform a real module resolution dry-run.
45
46use std::path::Path;
47
48use algocline_core::PkgEntity;
49use tracing::warn;
50
51use super::super::manifest::{load_manifest, Manifest, ManifestEntry};
52use super::super::resolve::packages_dir;
53use super::super::source::PackageSource;
54use super::super::AppService;
55use super::repair::{
56    collect_path_missing, collect_unattached_dangling_symlinks, symlink_dangling_suggestion,
57    ProjectPathSource,
58};
59
60/// Same TTL discipline as `hub::CACHE_TTL_SECS` (hub.rs:96).
61/// Both consts must be kept in sync; if you change one, change the other.
62/// (hub's const is module-private, so we redefine here instead of importing.)
63const DOCTOR_CACHE_TTL_SECS: u64 = 3600;
64
65/// Classification of a single manifest-tracked package (read-only).
66#[derive(Debug)]
67enum DoctorOutcome {
68    /// Destination exists and is reachable — no action required.
69    Healthy,
70    /// Destination is a symlink whose target is missing.
71    SymlinkDangling { reason: String, suggestion: String },
72    /// Destination is missing (non-symlink). Install can heal from `source`.
73    InstalledMissing { reason: String, suggestion: String },
74    /// Package directory exists but one or more submodule files required by
75    /// `init.lua` are missing.
76    IncompletePkg {
77        missing_subs: Vec<String>,
78        suggestion: String,
79    },
80    /// Package directory exists but `init.lua` does not declare a valid
81    /// `M.meta.name` field (parse failure, missing field, or absent file).
82    MissingMeta { reason: String, suggestion: String },
83    /// Package directory exists and has a `spec/` directory, but contains
84    /// zero `*_spec.lua` files (opt-in: pkgs without `spec/` are skipped).
85    SpecMissing { reason: String, suggestion: String },
86}
87
88/// Accumulator for the JSON output buckets.
89#[derive(Default)]
90struct DoctorBuckets {
91    healthy: Vec<serde_json::Value>,
92    installed_missing: Vec<serde_json::Value>,
93    symlink_dangling: Vec<serde_json::Value>,
94    path_missing: Vec<serde_json::Value>,
95    incomplete_pkg: Vec<serde_json::Value>,
96    missing_meta: Vec<serde_json::Value>,
97    missing_hub_index: Vec<serde_json::Value>,
98    spec_missing: Vec<serde_json::Value>,
99    stale_cache: Vec<serde_json::Value>,
100}
101
102impl DoctorBuckets {
103    fn any_matched(&self) -> bool {
104        !self.healthy.is_empty()
105            || !self.installed_missing.is_empty()
106            || !self.symlink_dangling.is_empty()
107            || !self.path_missing.is_empty()
108            || !self.incomplete_pkg.is_empty()
109            || !self.missing_meta.is_empty()
110            || !self.missing_hub_index.is_empty()
111            || !self.spec_missing.is_empty()
112            || !self.stale_cache.is_empty()
113    }
114
115    fn into_json(self) -> String {
116        // All buckets are always emitted (empty arrays when no entries).
117        // `serde_json::json!` serializes keys alphabetically without the
118        // `preserve_order` feature — consumers parse as a Map, not by order.
119        serde_json::json!({
120            "healthy": self.healthy,
121            "incomplete_pkg": self.incomplete_pkg,
122            "installed_missing": self.installed_missing,
123            "missing_hub_index": self.missing_hub_index,
124            "missing_meta": self.missing_meta,
125            "path_missing": self.path_missing,
126            "spec_missing": self.spec_missing,
127            "stale_cache": self.stale_cache,
128            "symlink_dangling": self.symlink_dangling,
129        })
130        .to_string()
131    }
132}
133
134/// Parse static string-literal `require` calls from Lua source and return the
135/// list of submodule names that belong to `pkg_name`.
136///
137/// Recognized pattern (parenthesised string literal, single or double quote):
138/// ```text
139/// require("pkg_name.sub")
140/// require('pkg_name.sub')
141/// ```
142///
143/// **Not** recognized (MVP scope — false negatives are acceptable):
144/// - `require "foo.bar"` (no parentheses)
145/// - `require(variable)` (dynamic)
146/// - `require([[foo.bar]])` (long-string literal)
147fn extract_required_subs(lua_src: &str, pkg_name: &str) -> Vec<String> {
148    let mut subs = Vec::new();
149    let prefix = format!("{pkg_name}.");
150    let mut remaining = lua_src;
151
152    while let Some(pos) = remaining.find("require") {
153        remaining = &remaining[pos + "require".len()..];
154
155        // Skip whitespace after `require`.
156        let trimmed = remaining.trim_start_matches([' ', '\t']);
157
158        // Must be followed by `(`.
159        if !trimmed.starts_with('(') {
160            continue;
161        }
162        let after_paren = &trimmed[1..];
163        let after_paren = after_paren.trim_start_matches([' ', '\t']);
164
165        // Must be followed by a string quote.
166        let quote = match after_paren.chars().next() {
167            Some(q @ '"') | Some(q @ '\'') => q,
168            _ => continue,
169        };
170        let content = &after_paren[1..];
171        let end = match content.find(quote) {
172            Some(i) => i,
173            None => continue,
174        };
175        let module = &content[..end];
176
177        if let Some(sub) = module.strip_prefix(&prefix) {
178            if !sub.is_empty() && !sub.contains('.') {
179                // Only direct children: `pkg.sub`, not `pkg.sub.deeper`.
180                subs.push(sub.to_string());
181            }
182        }
183    }
184
185    subs.sort();
186    subs.dedup();
187    subs
188}
189
190/// Build the suggestion string for an `incomplete_pkg` entry, branched by
191/// whether the package came from a symlink (link path) or an installed copy.
192fn incomplete_pkg_suggestion(name: &str, is_symlink: bool) -> String {
193    if is_symlink {
194        format!("Re-run alc_pkg_link <path> to re-link {name:?} with the complete source directory")
195    } else {
196        format!(
197            "Run alc_pkg_install --force {name:?} to reinstall {name:?} with all submodule files"
198        )
199    }
200}
201
202/// Suggestion string for `installed_missing`, branched by source kind to
203/// mirror `pkg_repair`'s routing:
204///
205/// - `Git` → `alc_pkg_install(<url>)`
206/// - `Path` → `alc_pkg_install(<path>)` (local re-copy)
207/// - `Bundled` → `alc_init` (bundled packages cannot be reinstalled via
208///   `alc_pkg_install`; they ship inside the algocline binary)
209/// - `Installed` → legacy marker with no re-fetch info (user must re-record
210///   source via `alc_pkg_install`)
211/// - `Unknown` → reindex + reinstall (pre-typed manifest with no source)
212fn installed_missing_suggestion(name: &str, entry_source: &PackageSource) -> String {
213    match entry_source {
214        PackageSource::Bundled { .. } => {
215            "alc_init (reinstalls bundled packages from the algocline binary)".to_string()
216        }
217        PackageSource::Path { path } => {
218            format!("alc_pkg_install({path:?}) to reinstall {name:?} from local path")
219        }
220        PackageSource::Git { url, .. } => {
221            format!("alc_pkg_install({url:?}) to reinstall {name:?} from Git")
222        }
223        PackageSource::Installed => {
224            format!(
225                "alc_pkg_install <path-or-url> to re-record source for {name:?} \
226                 (legacy 'installed' marker carries no path)"
227            )
228        }
229        PackageSource::Unknown => {
230            format!(
231                "alc_hub_reindex then alc_pkg_install <path-or-url> for {name:?} \
232                 (source unknown — legacy entry)"
233            )
234        }
235    }
236}
237
238/// Push a manifest-pass outcome into the appropriate bucket.
239fn push_doctor_outcome(name: &str, outcome: DoctorOutcome, buckets: &mut DoctorBuckets) {
240    match outcome {
241        DoctorOutcome::Healthy => buckets.healthy.push(serde_json::json!({
242            "name": name,
243        })),
244        DoctorOutcome::SymlinkDangling { reason, suggestion } => {
245            buckets.symlink_dangling.push(serde_json::json!({
246                "name": name,
247                "kind": "symlink_dangling",
248                "reason": reason,
249                "suggestion": suggestion,
250            }))
251        }
252        DoctorOutcome::InstalledMissing { reason, suggestion } => {
253            buckets.installed_missing.push(serde_json::json!({
254                "name": name,
255                "kind": "installed_missing",
256                "reason": reason,
257                "suggestion": suggestion,
258            }))
259        }
260        DoctorOutcome::IncompletePkg {
261            missing_subs,
262            suggestion,
263        } => buckets.incomplete_pkg.push(serde_json::json!({
264            "name": name,
265            "kind": "incomplete_pkg",
266            "missing_subs": missing_subs,
267            "suggestion": suggestion,
268        })),
269        DoctorOutcome::MissingMeta { reason, suggestion } => {
270            buckets.missing_meta.push(serde_json::json!({
271                "name": name,
272                "kind": "missing_meta",
273                "reason": reason,
274                "suggestion": suggestion,
275            }))
276        }
277        DoctorOutcome::SpecMissing { reason, suggestion } => {
278            buckets.spec_missing.push(serde_json::json!({
279                "name": name,
280                "kind": "spec_missing",
281                "reason": reason,
282                "suggestion": suggestion,
283            }))
284        }
285    }
286}
287
288/// Check whether the package directory at `dest` is incomplete: read
289/// `init.lua`, extract static `require("pkg.sub")` calls, and verify that
290/// each referenced sub-file exists as `{dest}/{sub}.lua` or
291/// `{dest}/{sub}/init.lua`.
292///
293/// Returns `Some(DoctorOutcome::IncompletePkg { .. })` when one or more
294/// submodule files are missing, `None` when everything is present or when
295/// `init.lua` cannot be read (IO errors are logged as warnings and treated as
296/// "no incomplete evidence" rather than propagated — the directory-level
297/// `Healthy` classification already passed and the init.lua read is
298/// best-effort).
299fn check_incomplete(name: &str, dest: &Path, is_symlink: bool) -> Option<DoctorOutcome> {
300    let init_lua = dest.join("init.lua");
301    let src = match std::fs::read_to_string(&init_lua) {
302        Ok(s) => s,
303        Err(e) => {
304            warn!(
305                error = %e,
306                path = %init_lua.display(),
307                "could not read init.lua for incomplete check; skipping"
308            );
309            return None;
310        }
311    };
312
313    let required_subs = extract_required_subs(&src, name);
314    if required_subs.is_empty() {
315        return None;
316    }
317
318    let missing: Vec<String> = required_subs
319        .into_iter()
320        .filter(|sub| {
321            let as_file = dest.join(format!("{sub}.lua"));
322            let as_dir = dest.join(sub).join("init.lua");
323            !as_file.exists() && !as_dir.exists()
324        })
325        .collect();
326
327    if missing.is_empty() {
328        return None;
329    }
330
331    Some(DoctorOutcome::IncompletePkg {
332        missing_subs: missing,
333        suggestion: incomplete_pkg_suggestion(name, is_symlink),
334    })
335}
336
337/// Check whether the package at `dest` has a valid `M.meta.name` declaration
338/// in its `init.lua`.
339///
340/// Uses [`PkgEntity::parse_from_init_lua`], which returns `None` for IO
341/// errors, parse failures, or a missing/empty `M.meta` block. All three cases
342/// are treated as `missing_meta` (the best-effort contract of the core parser).
343///
344/// Returns `Some(DoctorOutcome::MissingMeta { .. })` when `parse_from_init_lua`
345/// returns `None`; `None` when the package metadata is present and valid.
346fn check_missing_meta(name: &str, dest: &Path) -> Option<DoctorOutcome> {
347    let init_lua = dest.join("init.lua");
348    if PkgEntity::parse_from_init_lua(&init_lua).is_some() {
349        return None;
350    }
351    Some(DoctorOutcome::MissingMeta {
352        reason: format!("init.lua at {} lacks M.meta.name", init_lua.display()),
353        suggestion: format!(
354            "Package directory at {} lacks M.meta.name in init.lua — \
355             run alc_pkg_install --force {name:?} or fix init.lua to declare \
356             M.meta = {{ name = ..., version = ... }}",
357            dest.display()
358        ),
359    })
360}
361
362/// Check whether the package directory at `dest` has a `spec/` directory
363/// that exists but contains zero `*_spec.lua` files. Narrow scope (opt-in):
364/// packages without a `spec/` directory return `Ok(None)` silently.
365///
366/// Aligns with `alc_pkg_test`'s spec discovery convention
367/// (`<pkg_root>/spec/*_spec.lua`).
368///
369/// Returns `Err(String)` when `fs::read_dir` / `DirEntry::file_type` fails
370/// (judgment-critical fs ops; silent drop would produce false negatives).
371fn check_spec_missing(name: &str, dest: &Path) -> Result<Option<DoctorOutcome>, String> {
372    let spec_dir = dest.join("spec");
373    if !spec_dir.is_dir() {
374        return Ok(None);
375    }
376    let entries = std::fs::read_dir(&spec_dir).map_err(|e| {
377        format!(
378            "spec_missing: failed to read_dir {}: {e}",
379            spec_dir.display()
380        )
381    })?;
382    let mut found_spec = false;
383    for entry in entries {
384        let entry = entry.map_err(|e| format!("spec_missing: failed to read dir entry: {e}"))?;
385        let ft = entry.file_type().map_err(|e| {
386            format!(
387                "spec_missing: failed to read file_type for {}: {e}",
388                entry.path().display()
389            )
390        })?;
391        if !ft.is_file() {
392            continue;
393        }
394        let fname = entry.file_name();
395        if fname.to_string_lossy().ends_with("_spec.lua") {
396            found_spec = true;
397            break;
398        }
399    }
400    if found_spec {
401        return Ok(None);
402    }
403    Ok(Some(DoctorOutcome::SpecMissing {
404        reason: format!(
405            "spec directory at {} exists but contains zero *_spec.lua files",
406            spec_dir.display()
407        ),
408        suggestion: format!(
409            "Package {name:?} declared test intent by creating spec/ at {} — \
410             add at least one <name>_spec.lua file (mlua-lspec convention) or remove \
411             the spec/ directory to opt out of spec discipline",
412            spec_dir.display()
413        ),
414    }))
415}
416
417/// Classify a manifest entry by inspecting only the destination directory.
418/// Mirrors the pre-install branch of [`super::repair::repair_installed`] but
419/// never attempts an install.
420///
421/// After confirming the package directory is reachable, performs an additional
422/// best-effort incomplete check: reads `init.lua` to detect missing sibling
423/// submodule files. See [`check_incomplete`].
424fn classify_installed(
425    name: &str,
426    entry: &ManifestEntry,
427    pkg_dir: &Path,
428) -> Result<DoctorOutcome, String> {
429    let dest = pkg_dir.join(name);
430
431    let is_symlink = dest
432        .symlink_metadata()
433        .map(|m| m.file_type().is_symlink())
434        .unwrap_or(false);
435    if is_symlink {
436        // `try_exists` follows the symlink — true iff target is alive.
437        let target_alive = match dest.try_exists() {
438            Ok(v) => v,
439            Err(e) => {
440                warn!(error = %e, path = %dest.display(), "try_exists failed; treating symlink target as dead");
441                false
442            }
443        };
444        if target_alive {
445            // Symlink alive — check for missing submodule files, then meta, then spec.
446            if let Some(incomplete) = check_incomplete(name, &dest, true) {
447                return Ok(incomplete);
448            }
449            if let Some(mm) = check_missing_meta(name, &dest) {
450                return Ok(mm);
451            }
452            if let Some(sm) = check_spec_missing(name, &dest)? {
453                return Ok(sm);
454            }
455            return Ok(DoctorOutcome::Healthy);
456        }
457        let link_target = match dest.read_link() {
458            Ok(t) => t.display().to_string(),
459            Err(e) => {
460                warn!(error = %e, path = %dest.display(), "read_link failed; using placeholder for dangling target");
461                "<unknown>".to_string()
462            }
463        };
464        return Ok(DoctorOutcome::SymlinkDangling {
465            reason: format!("symlink target missing: {link_target}"),
466            suggestion: symlink_dangling_suggestion(name),
467        });
468    }
469
470    if dest.exists() {
471        // Directory exists — check for missing submodule files, then meta, then spec.
472        if let Some(incomplete) = check_incomplete(name, &dest, false) {
473            return Ok(incomplete);
474        }
475        if let Some(mm) = check_missing_meta(name, &dest) {
476            return Ok(mm);
477        }
478        if let Some(sm) = check_spec_missing(name, &dest)? {
479            return Ok(sm);
480        }
481        return Ok(DoctorOutcome::Healthy);
482    }
483
484    Ok(DoctorOutcome::InstalledMissing {
485        reason: format!("installed directory missing: {}", dest.display()),
486        suggestion: installed_missing_suggestion(name, &entry.source),
487    })
488}
489
490/// Classify every manifest entry into the four buckets. When `target_filter`
491/// is `Some(name)`, look the entry up directly (O(log N) on BTreeMap) instead
492/// of scanning the full map.
493fn run_manifest_pass(
494    manifest: &Manifest,
495    target_filter: Option<&str>,
496    pkg_dir: &Path,
497    buckets: &mut DoctorBuckets,
498) -> Result<(), String> {
499    if let Some(target) = target_filter {
500        if let Some(entry) = manifest.packages.get(target) {
501            let outcome = classify_installed(target, entry, pkg_dir)?;
502            push_doctor_outcome(target, outcome, buckets);
503        }
504        return Ok(());
505    }
506    for (pkg_name, entry) in &manifest.packages {
507        let outcome = classify_installed(pkg_name, entry, pkg_dir)?;
508        push_doctor_outcome(pkg_name, outcome, buckets);
509    }
510    Ok(())
511}
512
513/// Drain the unattached-symlink scan results into the `symlink_dangling`
514/// bucket. The shared helper writes tagged entries into a scratch vec so
515/// its signature can stay aligned with `pkg_repair`'s unrepairable bucket.
516fn run_unattached_symlink_pass(
517    pkg_dir: &Path,
518    target_filter: Option<&str>,
519    manifest: &Manifest,
520    buckets: &mut DoctorBuckets,
521) {
522    let mut scratch: Vec<serde_json::Value> = Vec::new();
523    collect_unattached_dangling_symlinks(pkg_dir, target_filter, &manifest.packages, &mut scratch);
524    buckets.symlink_dangling.extend(scratch);
525}
526
527/// Scan `alc.toml` + `alc.local.toml` for declared paths that no longer
528/// resolve. `resolved_root = None` means no project context was located,
529/// which mirrors `pkg_repair`'s skip-on-missing behavior.
530fn run_path_missing_pass(
531    resolved_root: Option<&Path>,
532    target_filter: Option<&str>,
533    buckets: &mut DoctorBuckets,
534) {
535    let Some(root) = resolved_root else {
536        return;
537    };
538    let mut scratch: Vec<serde_json::Value> = Vec::new();
539    collect_path_missing(
540        root,
541        target_filter,
542        "project",
543        &mut scratch,
544        ProjectPathSource::Toml,
545    );
546    collect_path_missing(
547        root,
548        target_filter,
549        "variant",
550        &mut scratch,
551        ProjectPathSource::Local,
552    );
553    buckets.path_missing.extend(scratch);
554}
555
556/// Scan the collection project root for a missing `hub_index.json`.
557///
558/// Fires when **all three** conditions hold:
559/// 1. Called only when `target_filter` is `None` and `resolved_root` is `Some`
560///    (enforced by the caller in [`AppService::pkg_doctor`]).
561/// 2. Two or more direct subdirectories of `root` each contain an `init.lua`
562///    file (collection-repo heuristic — single-pkg repos have fewer than 2).
563/// 3. `{root}/hub_index.json` does not exist.
564///
565/// All `fs` errors are propagated through `?` to the MCP wire layer; none are
566/// silently swallowed.
567///
568/// # Errors
569///
570/// Returns `Err(String)` when `fs::read_dir`, `DirEntry::file_type`, or
571/// `Path::try_exists` fails. The error message carries enough context to
572/// identify which path triggered the failure.
573fn run_hub_index_pass(root: &Path, buckets: &mut DoctorBuckets) -> Result<(), String> {
574    let mut pkg_count = 0usize;
575    let entries = std::fs::read_dir(root).map_err(|e| {
576        format!(
577            "hub_index_pass: failed to read project_root {}: {e}",
578            root.display()
579        )
580    })?;
581    for entry in entries {
582        let entry = entry.map_err(|e| format!("hub_index_pass: failed to read dir entry: {e}"))?;
583        let ft = entry
584            .file_type()
585            .map_err(|e| format!("hub_index_pass: failed to read file_type: {e}"))?;
586        if !ft.is_dir() {
587            continue;
588        }
589        let init_lua = entry.path().join("init.lua");
590        let exists = init_lua.try_exists().map_err(|e| {
591            format!(
592                "hub_index_pass: try_exists failed for {}: {e}",
593                init_lua.display()
594            )
595        })?;
596        if exists {
597            pkg_count += 1;
598        }
599    }
600    if pkg_count < 2 {
601        return Ok(());
602    }
603    let hub_index = root.join("hub_index.json");
604    let has_index = hub_index.try_exists().map_err(|e| {
605        format!(
606            "hub_index_pass: try_exists failed for {}: {e}",
607            hub_index.display()
608        )
609    })?;
610    if has_index {
611        return Ok(());
612    }
613    buckets.missing_hub_index.push(serde_json::json!({
614        "kind": "missing_hub_index",
615        "project_root": root.display().to_string(),
616        "pkg_count": pkg_count,
617        "suggestion": format!(
618            "Collection project root contains {pkg_count} package dirs but \
619             {}/hub_index.json is missing — run alc_hub_reindex --source_dir {} \
620             to generate it",
621            root.display(),
622            root.display()
623        ),
624    }));
625    Ok(())
626}
627
628/// Scan `cache_dir` (= `~/.algocline/hub_cache/`) for `*.json` files whose
629/// mtime is older than `DOCTOR_CACHE_TTL_SECS` (3600s) and emit one
630/// `stale_cache` entry per stale file. Skips when `cache_dir` does not
631/// exist (cache miss = first run = normal, not an error).
632///
633/// `mtime` retrieval failures (`metadata.modified()` / `.elapsed()`) are
634/// treated as "judgment impossible → safe-side fresh" and the file is
635/// skipped, *not* silently dropped. This is an explicit cross-platform
636/// fallback (older filesystems may not support `modified()`, and a system
637/// clock that drifted earlier than the file's mtime produces
638/// `SystemTimeError` from `.elapsed()`).
639///
640/// Returns `Err(String)` when `try_exists` / `read_dir` / `file_type` /
641/// `metadata` fail (these are judgment-critical fs ops; silent drop would
642/// produce false negatives).
643fn run_stale_cache_pass(cache_dir: &Path, buckets: &mut DoctorBuckets) -> Result<(), String> {
644    let exists = cache_dir.try_exists().map_err(|e| {
645        format!(
646            "stale_cache_pass: try_exists failed for {}: {e}",
647            cache_dir.display()
648        )
649    })?;
650    if !exists {
651        return Ok(());
652    }
653    let entries = std::fs::read_dir(cache_dir).map_err(|e| {
654        format!(
655            "stale_cache_pass: failed to read_dir {}: {e}",
656            cache_dir.display()
657        )
658    })?;
659    for entry in entries {
660        let entry =
661            entry.map_err(|e| format!("stale_cache_pass: failed to read dir entry: {e}"))?;
662        let ft = entry.file_type().map_err(|e| {
663            format!(
664                "stale_cache_pass: failed to read file_type for {}: {e}",
665                entry.path().display()
666            )
667        })?;
668        if !ft.is_file() {
669            continue;
670        }
671        let path = entry.path();
672        if path.extension().and_then(|s| s.to_str()) != Some("json") {
673            continue;
674        }
675        let metadata = entry.metadata().map_err(|e| {
676            format!(
677                "stale_cache_pass: failed to read metadata for {}: {e}",
678                path.display()
679            )
680        })?;
681        // mtime retrieval — `.ok()` here is intentional cross-platform
682        // fallback (see fn doc), not a silent error drop.
683        let Some(modified) = metadata.modified().ok() else {
684            continue;
685        };
686        let Some(age) = modified.elapsed().ok() else {
687            continue;
688        };
689        if age.as_secs() <= DOCTOR_CACHE_TTL_SECS {
690            continue;
691        }
692        buckets.stale_cache.push(serde_json::json!({
693            "kind": "stale_cache",
694            "path": path.display().to_string(),
695            "age_secs": age.as_secs(),
696            "suggestion": format!(
697                "Run alc_hub_search to refresh stale cache (>{DOCTOR_CACHE_TTL_SECS}s old)"
698            ),
699        }));
700    }
701    Ok(())
702}
703
704impl AppService {
705    /// Diagnose package state without any side effects. Returns a JSON string
706    /// with nine arrays (`healthy`, `incomplete_pkg`, `installed_missing`,
707    /// `missing_hub_index`, `missing_meta`, `path_missing`, `spec_missing`,
708    /// `stale_cache`, `symlink_dangling`).
709    ///
710    /// `name` restricts the report to a single package; `None` inspects every
711    /// known package. `project_root` is only consulted for the
712    /// `alc.toml` / `alc.local.toml` pass and the `missing_hub_index` scan.
713    /// Falls back to ancestor walk from cwd when `None`.
714    ///
715    /// Error surface matches `pkg_repair`:
716    /// - `load_manifest()` / `packages_dir()` failures propagate via `?`.
717    /// - Per-entry `fs::read_dir` errors inside the unattached-symlink scan
718    ///   are logged via `tracing::warn!` and skipped (helper's behavior).
719    /// - `init.lua` read errors during the incomplete check are logged via
720    ///   `tracing::warn!` and skipped (best-effort, no propagation).
721    /// - `fs::read_dir` / `try_exists` errors inside `run_hub_index_pass`
722    ///   propagate via `?` (collection-root scan requires reliable fs access).
723    /// - `try_exists` / `read_dir` / `file_type` / `metadata` errors inside
724    ///   `run_stale_cache_pass` propagate via `?`. `metadata.modified()` and
725    ///   `.elapsed()` failures (cross-platform fallback) skip the file.
726    /// - When `name = Some(target)` and every bucket ends empty, returns
727    ///   `Err` with the same wording used by `pkg_repair`.
728    pub async fn pkg_doctor(
729        &self,
730        name: Option<String>,
731        project_root: Option<String>,
732    ) -> Result<String, String> {
733        let app_dir = self.log_config.app_dir();
734        let manifest = load_manifest(&app_dir)?;
735        let pkg_dir = packages_dir(&app_dir);
736        let resolved_root = self.resolve_root(project_root.as_deref());
737        let target_filter = name.as_deref();
738
739        let mut buckets = DoctorBuckets::default();
740        run_manifest_pass(&manifest, target_filter, &pkg_dir, &mut buckets)?;
741        run_unattached_symlink_pass(&pkg_dir, target_filter, &manifest, &mut buckets);
742        run_path_missing_pass(resolved_root.as_deref(), target_filter, &mut buckets);
743        if target_filter.is_none() {
744            run_stale_cache_pass(&app_dir.hub_cache_dir(), &mut buckets)?;
745            if let Some(ref root) = resolved_root {
746                run_hub_index_pass(root, &mut buckets)?;
747            }
748        }
749
750        if let Some(target) = target_filter {
751            if !buckets.any_matched() {
752                return Err(format!(
753                    "Package '{target}' not found in installed.json, ~/.algocline/packages/, alc.toml, or alc.local.toml"
754                ));
755            }
756        }
757
758        Ok(buckets.into_json())
759    }
760}
761
762#[cfg(test)]
763mod tests {
764    use super::*;
765    use std::path::PathBuf;
766
767    /// Build a minimal `ManifestEntry` with a `PackageSource::Path`.
768    /// Takes a legacy path string so the existing tests keep reading
769    /// naturally; the arg is wrapped into the typed `Path` variant.
770    fn mk_entry(source: &str) -> ManifestEntry {
771        ManifestEntry {
772            version: None,
773            source: PackageSource::Path {
774                path: source.to_string(),
775            },
776            installed_at: "2026-01-01T00:00:00Z".to_string(),
777            updated_at: "2026-01-01T00:00:00Z".to_string(),
778        }
779    }
780
781    #[test]
782    fn classify_installed_healthy_dir() {
783        let tmp = tempfile::tempdir().unwrap();
784        let pkg_dir = tmp.path();
785        let dest = pkg_dir.join("p");
786        std::fs::create_dir(&dest).unwrap();
787        // init.lua with valid M.meta so check_missing_meta does not fire.
788        std::fs::write(
789            dest.join("init.lua"),
790            "local M = {} M.meta = { name = \"p\", version = \"0.1.0\" } return M",
791        )
792        .unwrap();
793
794        let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir).expect("classify ok");
795        assert!(matches!(outcome, DoctorOutcome::Healthy));
796    }
797
798    #[test]
799    fn classify_installed_missing_dir() {
800        let tmp = tempfile::tempdir().unwrap();
801        let pkg_dir = tmp.path();
802
803        let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir).expect("classify ok");
804        match outcome {
805            DoctorOutcome::InstalledMissing { reason, suggestion } => {
806                assert!(
807                    reason.contains("installed directory missing"),
808                    "reason = {reason}"
809                );
810                assert!(
811                    suggestion.contains("alc_pkg_install"),
812                    "suggestion = {suggestion}"
813                );
814                assert!(
815                    suggestion.contains("/src/p"),
816                    "suggestion carries source: {suggestion}"
817                );
818            }
819            _ => panic!("expected InstalledMissing"),
820        }
821    }
822
823    #[test]
824    #[cfg(unix)]
825    fn classify_installed_symlink_dangling() {
826        use std::os::unix::fs::symlink;
827
828        let tmp = tempfile::tempdir().unwrap();
829        let pkg_dir = tmp.path();
830        let dangling_target = PathBuf::from("/nonexistent/path/for/doctor_test");
831        symlink(&dangling_target, pkg_dir.join("p")).unwrap();
832
833        let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir).expect("classify ok");
834        match outcome {
835            DoctorOutcome::SymlinkDangling { reason, suggestion } => {
836                assert!(reason.contains("symlink target missing"), "{reason}");
837                assert!(suggestion.contains("alc_pkg_unlink"), "{suggestion}");
838            }
839            _ => panic!("expected SymlinkDangling"),
840        }
841    }
842
843    #[test]
844    #[cfg(unix)]
845    fn classify_installed_symlink_alive() {
846        use std::os::unix::fs::symlink;
847
848        let tmp = tempfile::tempdir().unwrap();
849        let real_target = tmp.path().join("real_target_dir");
850        std::fs::create_dir(&real_target).unwrap();
851        // init.lua with valid M.meta so check_missing_meta does not fire.
852        std::fs::write(
853            real_target.join("init.lua"),
854            "local M = {} M.meta = { name = \"q\", version = \"0.1.0\" } return M",
855        )
856        .unwrap();
857
858        let pkg_dir = tmp.path().join("pkgs");
859        std::fs::create_dir(&pkg_dir).unwrap();
860        symlink(&real_target, pkg_dir.join("q")).unwrap();
861
862        let outcome = classify_installed("q", &mk_entry("/src/q"), &pkg_dir).expect("classify ok");
863        assert!(matches!(outcome, DoctorOutcome::Healthy));
864    }
865
866    #[test]
867    fn buckets_into_json_emits_all_nine_keys() {
868        // NOTE: `serde_json` without the `preserve_order` feature emits JSON
869        // object keys in alphabetical order, matching `pkg_repair`'s actual
870        // behavior. The spec's "fixed order" requirement is satisfied by
871        // always emitting these nine top-level keys; consumers parse as a
872        // Map rather than relying on textual key order.
873        //
874        // Note: `narrative_issues` bucket removed in #1778221491-39903.
875        let mut b = DoctorBuckets::default();
876        b.healthy.push(serde_json::json!({"name": "h"}));
877        b.installed_missing
878            .push(serde_json::json!({"name": "i", "kind": "installed_missing"}));
879        b.symlink_dangling
880            .push(serde_json::json!({"name": "s", "kind": "symlink_dangling"}));
881        b.path_missing
882            .push(serde_json::json!({"name": "p", "kind": "path_missing"}));
883        b.incomplete_pkg
884            .push(serde_json::json!({"name": "c", "kind": "incomplete_pkg"}));
885        b.missing_meta
886            .push(serde_json::json!({"name": "m", "kind": "missing_meta"}));
887        b.missing_hub_index
888            .push(serde_json::json!({"kind": "missing_hub_index", "project_root": "/r"}));
889        b.spec_missing
890            .push(serde_json::json!({"name": "sm", "kind": "spec_missing"}));
891        b.stale_cache
892            .push(serde_json::json!({"kind": "stale_cache", "path": "/p", "age_secs": 7200}));
893
894        let out = b.into_json();
895        let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
896        let obj = parsed.as_object().expect("JSON object");
897        assert!(obj.contains_key("healthy"));
898        assert!(obj.contains_key("installed_missing"));
899        assert!(obj.contains_key("symlink_dangling"));
900        assert!(obj.contains_key("path_missing"));
901        assert!(obj.contains_key("incomplete_pkg"));
902        assert!(obj.contains_key("missing_meta"));
903        assert!(obj.contains_key("missing_hub_index"));
904        assert!(obj.contains_key("spec_missing"));
905        assert!(obj.contains_key("stale_cache"));
906        assert_eq!(obj.len(), 9, "exactly nine top-level buckets: {out}");
907
908        assert_eq!(obj["healthy"][0]["name"], "h");
909        assert_eq!(obj["installed_missing"][0]["name"], "i");
910        assert_eq!(obj["symlink_dangling"][0]["name"], "s");
911        assert_eq!(obj["path_missing"][0]["name"], "p");
912        assert_eq!(obj["incomplete_pkg"][0]["name"], "c");
913        assert_eq!(obj["missing_meta"][0]["name"], "m");
914        assert_eq!(obj["missing_hub_index"][0]["project_root"], "/r");
915        assert_eq!(obj["spec_missing"][0]["name"], "sm");
916        assert_eq!(obj["stale_cache"][0]["path"], "/p");
917    }
918
919    #[test]
920    fn any_matched_tracks_all_buckets() {
921        let mut b = DoctorBuckets::default();
922        assert!(!b.any_matched());
923        b.healthy.push(serde_json::json!({"name": "h"}));
924        assert!(b.any_matched());
925
926        let mut b = DoctorBuckets::default();
927        b.installed_missing.push(serde_json::json!({}));
928        assert!(b.any_matched());
929
930        let mut b = DoctorBuckets::default();
931        b.symlink_dangling.push(serde_json::json!({}));
932        assert!(b.any_matched());
933
934        let mut b = DoctorBuckets::default();
935        b.path_missing.push(serde_json::json!({}));
936        assert!(b.any_matched());
937
938        let mut b = DoctorBuckets::default();
939        b.incomplete_pkg.push(serde_json::json!({}));
940        assert!(b.any_matched());
941
942        let mut b = DoctorBuckets::default();
943        b.missing_meta.push(serde_json::json!({}));
944        assert!(b.any_matched());
945
946        let mut b = DoctorBuckets::default();
947        b.missing_hub_index.push(serde_json::json!({}));
948        assert!(b.any_matched());
949
950        let mut b = DoctorBuckets::default();
951        b.spec_missing.push(serde_json::json!({}));
952        assert!(b.any_matched());
953
954        let mut b = DoctorBuckets::default();
955        b.stale_cache.push(serde_json::json!({}));
956        assert!(b.any_matched());
957    }
958
959    // ── spec_missing ─────────────────────────────────────────────────────
960
961    /// T1: pkg has spec/foo_spec.lua → check_spec_missing returns Ok(None).
962    #[test]
963    fn check_spec_missing_returns_none_when_spec_file_present() {
964        let tmp = tempfile::tempdir().unwrap();
965        let dest = tmp.path().join("mypkg");
966        std::fs::create_dir_all(dest.join("spec")).unwrap();
967        std::fs::write(dest.join("spec/foo_spec.lua"), "return {}").unwrap();
968        let out = check_spec_missing("mypkg", &dest).expect("must not error");
969        assert!(out.is_none(), "expected None, got: {out:?}");
970    }
971
972    /// T2: pkg has empty spec/ → SpecMissing emitted with reason+suggestion.
973    #[test]
974    fn check_spec_missing_detects_empty_spec_dir() {
975        let tmp = tempfile::tempdir().unwrap();
976        let dest = tmp.path().join("mypkg");
977        std::fs::create_dir_all(dest.join("spec")).unwrap();
978        let out = check_spec_missing("mypkg", &dest)
979            .expect("must not error")
980            .expect("expected SpecMissing");
981        match out {
982            DoctorOutcome::SpecMissing { reason, suggestion } => {
983                assert!(reason.contains("spec"), "reason: {reason}");
984                assert!(suggestion.contains("_spec.lua"), "suggestion: {suggestion}");
985            }
986            _ => panic!("expected SpecMissing, got {out:?}"),
987        }
988    }
989
990    /// T3: pkg has spec/ with only non-spec files → SpecMissing emitted.
991    #[test]
992    fn check_spec_missing_detects_spec_dir_with_only_non_spec_files() {
993        let tmp = tempfile::tempdir().unwrap();
994        let dest = tmp.path().join("mypkg");
995        std::fs::create_dir_all(dest.join("spec")).unwrap();
996        std::fs::write(dest.join("spec/helper.lua"), "return {}").unwrap();
997        std::fs::write(dest.join("spec/README.md"), "docs").unwrap();
998        let out = check_spec_missing("mypkg", &dest)
999            .expect("must not error")
1000            .expect("expected SpecMissing");
1001        assert!(matches!(out, DoctorOutcome::SpecMissing { .. }));
1002    }
1003
1004    /// T4: pkg has no spec/ → Ok(None) (silent skip, opt-in scope).
1005    #[test]
1006    fn check_spec_missing_silently_skips_when_spec_dir_absent() {
1007        let tmp = tempfile::tempdir().unwrap();
1008        let dest = tmp.path().join("mypkg");
1009        std::fs::create_dir_all(&dest).unwrap();
1010        let out = check_spec_missing("mypkg", &dest).expect("must not error");
1011        assert!(
1012            out.is_none(),
1013            "expected None for absent spec/, got: {out:?}"
1014        );
1015    }
1016
1017    // ── stale_cache ──────────────────────────────────────────────────────
1018
1019    #[test]
1020    fn run_stale_cache_pass_emits_when_file_older_than_ttl() {
1021        let tmp = tempfile::tempdir().unwrap();
1022        let cache_dir = tmp.path();
1023        let stale_file = cache_dir.join("abc123.json");
1024        std::fs::write(&stale_file, "{}").unwrap();
1025        let past = std::time::SystemTime::now() - std::time::Duration::from_secs(7200);
1026        let times = std::fs::FileTimes::new().set_modified(past);
1027        let f = std::fs::OpenOptions::new()
1028            .write(true)
1029            .open(&stale_file)
1030            .unwrap();
1031        f.set_times(times).unwrap();
1032
1033        let mut buckets = DoctorBuckets::default();
1034        run_stale_cache_pass(cache_dir, &mut buckets).expect("must not error");
1035        assert_eq!(
1036            buckets.stale_cache.len(),
1037            1,
1038            "expected 1 stale entry: {:?}",
1039            buckets.stale_cache
1040        );
1041        let entry = &buckets.stale_cache[0];
1042        assert_eq!(entry["kind"], "stale_cache");
1043        assert!(entry["path"]
1044            .as_str()
1045            .unwrap_or("")
1046            .ends_with("abc123.json"));
1047        assert!(entry["age_secs"].as_u64().unwrap_or(0) >= 7200);
1048    }
1049
1050    #[test]
1051    fn run_stale_cache_pass_no_emit_for_fresh_file() {
1052        let tmp = tempfile::tempdir().unwrap();
1053        let cache_dir = tmp.path();
1054        let fresh_file = cache_dir.join("xyz789.json");
1055        std::fs::write(&fresh_file, "{}").unwrap();
1056        let mut buckets = DoctorBuckets::default();
1057        run_stale_cache_pass(cache_dir, &mut buckets).expect("must not error");
1058        assert!(
1059            buckets.stale_cache.is_empty(),
1060            "expected no stale entries for fresh file"
1061        );
1062    }
1063
1064    #[test]
1065    fn run_stale_cache_pass_skips_when_cache_dir_absent() {
1066        let tmp = tempfile::tempdir().unwrap();
1067        let missing_dir = tmp.path().join("nonexistent_cache");
1068        let mut buckets = DoctorBuckets::default();
1069        run_stale_cache_pass(&missing_dir, &mut buckets).expect("absent dir must skip with Ok");
1070        assert!(buckets.stale_cache.is_empty());
1071    }
1072
1073    #[test]
1074    fn run_stale_cache_pass_ignores_non_json_files() {
1075        let tmp = tempfile::tempdir().unwrap();
1076        let cache_dir = tmp.path();
1077        let garbage = cache_dir.join(".DS_Store");
1078        std::fs::write(&garbage, "garbage").unwrap();
1079        let past = std::time::SystemTime::now() - std::time::Duration::from_secs(7200);
1080        let times = std::fs::FileTimes::new().set_modified(past);
1081        let f = std::fs::OpenOptions::new()
1082            .write(true)
1083            .open(&garbage)
1084            .unwrap();
1085        f.set_times(times).unwrap();
1086
1087        let mut buckets = DoctorBuckets::default();
1088        run_stale_cache_pass(cache_dir, &mut buckets).expect("must not error");
1089        assert!(
1090            buckets.stale_cache.is_empty(),
1091            "non-json files must be ignored"
1092        );
1093    }
1094
1095    // ── missing_meta ─────────────────────────────────────────────────────
1096
1097    /// T1 (happy path): a package directory whose init.lua omits `M.meta`
1098    /// entirely is classified as `MissingMeta`.
1099    #[test]
1100    fn classify_installed_missing_meta_when_init_lua_lacks_meta() {
1101        let tmp = tempfile::tempdir().unwrap();
1102        let pkg_dir = tmp.path();
1103        let dest = pkg_dir.join("mypkg");
1104        std::fs::create_dir(&dest).unwrap();
1105        // init.lua present but declares no M.meta block.
1106        std::fs::write(dest.join("init.lua"), "local M = {} return M").unwrap();
1107
1108        let outcome =
1109            classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
1110        match outcome {
1111            DoctorOutcome::MissingMeta { reason, suggestion } => {
1112                assert!(reason.contains("lacks M.meta.name"), "reason: {reason}");
1113                assert!(
1114                    suggestion.contains("alc_pkg_install"),
1115                    "suggestion: {suggestion}"
1116                );
1117                assert!(
1118                    suggestion.contains("mypkg"),
1119                    "suggestion carries name: {suggestion}"
1120                );
1121            }
1122            _ => panic!("expected MissingMeta, got {outcome:?}"),
1123        }
1124    }
1125
1126    /// T2 (edge case): `M.meta = {{ name = "" }}` — empty name string —
1127    /// is treated as missing meta (parse_from_init_lua returns None for empty
1128    /// name via option_from_str).
1129    #[test]
1130    fn classify_installed_missing_meta_when_init_lua_has_empty_meta_name() {
1131        let tmp = tempfile::tempdir().unwrap();
1132        let pkg_dir = tmp.path();
1133        let dest = pkg_dir.join("mypkg");
1134        std::fs::create_dir(&dest).unwrap();
1135        // M.meta with an empty name string.
1136        std::fs::write(
1137            dest.join("init.lua"),
1138            "local M = {} M.meta = { name = \"\", version = \"0.1.0\" } return M",
1139        )
1140        .unwrap();
1141
1142        let outcome =
1143            classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
1144        assert!(
1145            matches!(outcome, DoctorOutcome::MissingMeta { .. }),
1146            "expected MissingMeta for empty name, got {outcome:?}"
1147        );
1148    }
1149
1150    /// T3 (no false positive): a complete init.lua with a valid `M.meta.name`
1151    /// must not trigger `MissingMeta`.
1152    #[test]
1153    fn classify_installed_no_missing_meta_when_init_lua_complete() {
1154        let tmp = tempfile::tempdir().unwrap();
1155        let pkg_dir = tmp.path();
1156        let dest = pkg_dir.join("mypkg");
1157        std::fs::create_dir(&dest).unwrap();
1158        std::fs::write(
1159            dest.join("init.lua"),
1160            "local M = {} M.meta = { name = \"mypkg\", version = \"0.1.0\" } return M",
1161        )
1162        .unwrap();
1163
1164        let outcome =
1165            classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
1166        assert!(
1167            matches!(outcome, DoctorOutcome::Healthy),
1168            "expected Healthy for complete init.lua, got {outcome:?}"
1169        );
1170    }
1171
1172    // ── run_hub_index_pass ────────────────────────────────────────────────
1173
1174    /// T1 (happy path): two subdirectories each containing init.lua, with no
1175    /// hub_index.json in the root — emits one `missing_hub_index` entry.
1176    #[test]
1177    fn run_hub_index_pass_emits_when_2_plus_pkgs_and_index_absent() {
1178        let tmp = tempfile::tempdir().unwrap();
1179        let root = tmp.path();
1180        // Two package dirs each with init.lua.
1181        for name in &["pkg_a", "pkg_b"] {
1182            let dir = root.join(name);
1183            std::fs::create_dir(&dir).unwrap();
1184            std::fs::write(dir.join("init.lua"), "return {}").unwrap();
1185        }
1186        // hub_index.json intentionally absent.
1187
1188        let mut buckets = DoctorBuckets::default();
1189        run_hub_index_pass(root, &mut buckets).expect("run_hub_index_pass must not error");
1190
1191        assert_eq!(
1192            buckets.missing_hub_index.len(),
1193            1,
1194            "expected 1 missing_hub_index entry: {:?}",
1195            buckets.missing_hub_index
1196        );
1197        let entry = &buckets.missing_hub_index[0];
1198        assert_eq!(entry["kind"], "missing_hub_index");
1199        assert_eq!(entry["pkg_count"], 2);
1200        assert!(
1201            entry["suggestion"]
1202                .as_str()
1203                .unwrap_or("")
1204                .contains("alc_hub_reindex"),
1205            "suggestion: {entry}"
1206        );
1207    }
1208
1209    /// T2 (boundary): only one package dir with init.lua — heuristic requires
1210    /// 2+, so no entry is emitted.
1211    #[test]
1212    fn run_hub_index_pass_skips_when_only_1_pkg_dir() {
1213        let tmp = tempfile::tempdir().unwrap();
1214        let root = tmp.path();
1215        let dir = root.join("pkg_a");
1216        std::fs::create_dir(&dir).unwrap();
1217        std::fs::write(dir.join("init.lua"), "return {}").unwrap();
1218        // No hub_index.json — but only 1 pkg dir, so must NOT fire.
1219
1220        let mut buckets = DoctorBuckets::default();
1221        run_hub_index_pass(root, &mut buckets).expect("run_hub_index_pass must not error");
1222
1223        assert!(
1224            buckets.missing_hub_index.is_empty(),
1225            "must not emit with only 1 pkg dir: {:?}",
1226            buckets.missing_hub_index
1227        );
1228    }
1229
1230    /// T3 (error path guard): two package dirs but hub_index.json already
1231    /// exists — no entry is emitted.
1232    #[test]
1233    fn run_hub_index_pass_skips_when_hub_index_exists() {
1234        let tmp = tempfile::tempdir().unwrap();
1235        let root = tmp.path();
1236        for name in &["pkg_a", "pkg_b"] {
1237            let dir = root.join(name);
1238            std::fs::create_dir(&dir).unwrap();
1239            std::fs::write(dir.join("init.lua"), "return {}").unwrap();
1240        }
1241        // hub_index.json present.
1242        std::fs::write(root.join("hub_index.json"), "{}").unwrap();
1243
1244        let mut buckets = DoctorBuckets::default();
1245        run_hub_index_pass(root, &mut buckets).expect("run_hub_index_pass must not error");
1246
1247        assert!(
1248            buckets.missing_hub_index.is_empty(),
1249            "must not emit when hub_index.json exists: {:?}",
1250            buckets.missing_hub_index
1251        );
1252    }
1253
1254    #[test]
1255    fn installed_missing_suggestion_shape() {
1256        let git = PackageSource::Git {
1257            url: "github.com/foo/bar".to_string(),
1258            rev: None,
1259        };
1260        let s = installed_missing_suggestion("ucb", &git);
1261        assert!(s.contains("alc_pkg_install"), "{s}");
1262        assert!(s.contains("\"ucb\""), "{s}");
1263        assert!(s.contains("github.com/foo/bar"), "{s}");
1264    }
1265
1266    /// A bundled-source entry must route the user to `alc_init`, NOT
1267    /// `alc_pkg_install("bundled")` (which would fail — bundled packages
1268    /// ship inside the algocline binary and are restored via `alc_init`).
1269    /// Mirrors `repair.rs` bundled arm.
1270    #[test]
1271    fn installed_missing_suggestion_routes_bundled_to_alc_init() {
1272        let bundled = PackageSource::Bundled { collection: None };
1273        let s = installed_missing_suggestion("ucb", &bundled);
1274        assert!(s.contains("alc_init"), "bundled must suggest alc_init: {s}");
1275        assert!(
1276            !s.contains("alc_pkg_install"),
1277            "bundled must NOT suggest alc_pkg_install: {s}"
1278        );
1279    }
1280
1281    /// A `Path` source entry emits a suggestion pointing at
1282    /// `alc_pkg_install(<path>)` — matching repair's LocalPath installer
1283    /// route. (Under the typed migration, `alc_pkg_install` now records
1284    /// local installs as `Path { path }` rather than the legacy
1285    /// `Installed` coercion, so this is the canonical local-reinstall
1286    /// suggestion.)
1287    #[test]
1288    fn installed_missing_suggestion_routes_absolute_path_to_pkg_install() {
1289        let local = PackageSource::Path {
1290            path: "/abs/path/to/src".to_string(),
1291        };
1292        let s = installed_missing_suggestion("local_pkg", &local);
1293        assert!(s.contains("alc_pkg_install"), "{s}");
1294        assert!(s.contains("/abs/path/to/src"), "{s}");
1295    }
1296
1297    /// `Unknown` source (legacy pre-typed entry with no recorded source)
1298    /// must route the user to `alc_hub_reindex` before attempting a
1299    /// reinstall — mirrors the `Unrepairable` routing in `repair.rs`.
1300    #[test]
1301    fn installed_missing_suggestion_routes_unknown_to_reindex() {
1302        let s = installed_missing_suggestion("legacy_pkg", &PackageSource::Unknown);
1303        assert!(
1304            s.contains("alc_hub_reindex"),
1305            "Unknown must suggest alc_hub_reindex: {s}"
1306        );
1307    }
1308
1309    // ── extract_required_subs ────────────────────────────────────────────
1310
1311    #[test]
1312    fn extract_subs_double_quote() {
1313        let src = r#"
1314local M = {}
1315local check = require("mypkg.check")
1316local t = require("mypkg.t")
1317return M
1318"#;
1319        let subs = extract_required_subs(src, "mypkg");
1320        assert_eq!(subs, vec!["check", "t"]);
1321    }
1322
1323    #[test]
1324    fn extract_subs_single_quote() {
1325        let src = "local x = require('mypkg.sub')";
1326        let subs = extract_required_subs(src, "mypkg");
1327        assert_eq!(subs, vec!["sub"]);
1328    }
1329
1330    #[test]
1331    fn extract_subs_ignores_other_packages() {
1332        let src = r#"
1333local x = require("other.sub")
1334local y = require("mypkg.mine")
1335"#;
1336        let subs = extract_required_subs(src, "mypkg");
1337        assert_eq!(subs, vec!["mine"]);
1338    }
1339
1340    #[test]
1341    fn extract_subs_deduplicates() {
1342        let src = r#"
1343local a = require("mypkg.check")
1344local b = require("mypkg.check")
1345"#;
1346        let subs = extract_required_subs(src, "mypkg");
1347        assert_eq!(subs, vec!["check"]);
1348    }
1349
1350    #[test]
1351    fn extract_subs_ignores_dynamic_require() {
1352        // Dynamic require (no parenthesised string literal) must not be detected.
1353        let src = r#"local x = require(mod_name)"#;
1354        let subs = extract_required_subs(src, "mypkg");
1355        assert!(subs.is_empty(), "dynamic require must be ignored: {subs:?}");
1356    }
1357
1358    #[test]
1359    fn extract_subs_ignores_nested_dots() {
1360        // Only direct children: `pkg.sub`, not `pkg.sub.deeper`.
1361        let src = r#"local x = require("mypkg.sub.deeper")"#;
1362        let subs = extract_required_subs(src, "mypkg");
1363        assert!(
1364            subs.is_empty(),
1365            "nested dotted require must be ignored: {subs:?}"
1366        );
1367    }
1368
1369    #[test]
1370    fn extract_subs_empty_for_no_require() {
1371        let src = r#"local M = {} return M"#;
1372        let subs = extract_required_subs(src, "mypkg");
1373        assert!(subs.is_empty());
1374    }
1375
1376    // ── check_incomplete ─────────────────────────────────────────────────
1377
1378    #[test]
1379    fn check_incomplete_returns_none_when_all_subs_present_as_lua() {
1380        let tmp = tempfile::tempdir().unwrap();
1381        let dest = tmp.path().join("mypkg");
1382        std::fs::create_dir(&dest).unwrap();
1383        std::fs::write(
1384            dest.join("init.lua"),
1385            r#"local c = require("mypkg.check") return {}"#,
1386        )
1387        .unwrap();
1388        std::fs::write(dest.join("check.lua"), "return {}").unwrap();
1389
1390        assert!(check_incomplete("mypkg", &dest, false).is_none());
1391    }
1392
1393    #[test]
1394    fn check_incomplete_returns_none_when_sub_is_dir_init() {
1395        let tmp = tempfile::tempdir().unwrap();
1396        let dest = tmp.path().join("mypkg");
1397        std::fs::create_dir(&dest).unwrap();
1398        std::fs::write(
1399            dest.join("init.lua"),
1400            r#"local c = require("mypkg.sub") return {}"#,
1401        )
1402        .unwrap();
1403        // sub/ directory with init.lua
1404        std::fs::create_dir(dest.join("sub")).unwrap();
1405        std::fs::write(dest.join("sub").join("init.lua"), "return {}").unwrap();
1406
1407        assert!(check_incomplete("mypkg", &dest, false).is_none());
1408    }
1409
1410    #[test]
1411    fn check_incomplete_detects_missing_sub() {
1412        let tmp = tempfile::tempdir().unwrap();
1413        let dest = tmp.path().join("mypkg");
1414        std::fs::create_dir(&dest).unwrap();
1415        std::fs::write(
1416            dest.join("init.lua"),
1417            r#"
1418local check = require("mypkg.check")
1419local t = require("mypkg.t")
1420return {}
1421"#,
1422        )
1423        .unwrap();
1424        // only `check.lua` present, `t.lua` missing
1425        std::fs::write(dest.join("check.lua"), "return {}").unwrap();
1426
1427        let outcome = check_incomplete("mypkg", &dest, false).expect("should detect incomplete");
1428        match outcome {
1429            DoctorOutcome::IncompletePkg {
1430                missing_subs,
1431                suggestion,
1432            } => {
1433                assert_eq!(missing_subs, vec!["t"], "missing_subs: {missing_subs:?}");
1434                assert!(
1435                    suggestion.contains("alc_pkg_install"),
1436                    "non-symlink suggestion: {suggestion}"
1437                );
1438            }
1439            _ => panic!("expected IncompletePkg"),
1440        }
1441    }
1442
1443    #[test]
1444    fn check_incomplete_suggestion_uses_link_for_symlink() {
1445        let tmp = tempfile::tempdir().unwrap();
1446        let dest = tmp.path().join("mypkg");
1447        std::fs::create_dir(&dest).unwrap();
1448        std::fs::write(
1449            dest.join("init.lua"),
1450            r#"local x = require("mypkg.missing") return {}"#,
1451        )
1452        .unwrap();
1453        // `missing.lua` absent
1454
1455        let outcome = check_incomplete("mypkg", &dest, true).expect("should detect incomplete");
1456        match outcome {
1457            DoctorOutcome::IncompletePkg { suggestion, .. } => {
1458                assert!(
1459                    suggestion.contains("alc_pkg_link"),
1460                    "symlink suggestion: {suggestion}"
1461                );
1462            }
1463            _ => panic!("expected IncompletePkg"),
1464        }
1465    }
1466
1467    #[test]
1468    fn check_incomplete_returns_none_when_no_init_lua() {
1469        // Package with no init.lua at all — best-effort skip, returns None.
1470        let tmp = tempfile::tempdir().unwrap();
1471        let dest = tmp.path().join("mypkg");
1472        std::fs::create_dir(&dest).unwrap();
1473
1474        assert!(check_incomplete("mypkg", &dest, false).is_none());
1475    }
1476
1477    #[test]
1478    fn classify_installed_incomplete_pkg() {
1479        // classify_installed should return IncompletePkg when sub.lua is missing.
1480        let tmp = tempfile::tempdir().unwrap();
1481        let pkg_dir = tmp.path();
1482        let dest = pkg_dir.join("mypkg");
1483        std::fs::create_dir(&dest).unwrap();
1484        std::fs::write(
1485            dest.join("init.lua"),
1486            r#"local x = require("mypkg.sub") return {}"#,
1487        )
1488        .unwrap();
1489        // sub.lua intentionally absent
1490
1491        let outcome =
1492            classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
1493        match outcome {
1494            DoctorOutcome::IncompletePkg {
1495                missing_subs,
1496                suggestion,
1497            } => {
1498                assert_eq!(missing_subs, vec!["sub"]);
1499                assert!(suggestion.contains("alc_pkg_install"), "{suggestion}");
1500            }
1501            _ => panic!("expected IncompletePkg, got {outcome:?}"),
1502        }
1503    }
1504
1505    #[test]
1506    fn classify_installed_healthy_when_all_subs_present() {
1507        // classify_installed should return Healthy when all required subs exist
1508        // and M.meta is declared in init.lua.
1509        let tmp = tempfile::tempdir().unwrap();
1510        let pkg_dir = tmp.path();
1511        let dest = pkg_dir.join("mypkg");
1512        std::fs::create_dir(&dest).unwrap();
1513        std::fs::write(
1514            dest.join("init.lua"),
1515            "local M = {}\n\
1516             M.meta = { name = \"mypkg\", version = \"0.1.0\" }\n\
1517             local x = require(\"mypkg.sub\")\n\
1518             return M",
1519        )
1520        .unwrap();
1521        std::fs::write(dest.join("sub.lua"), "return {}").unwrap();
1522
1523        let outcome =
1524            classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir).expect("classify ok");
1525        assert!(
1526            matches!(outcome, DoctorOutcome::Healthy),
1527            "expected Healthy, got {outcome:?}"
1528        );
1529    }
1530}