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