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 five 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//! | `path_missing`      | `alc.toml` / `alc.local.toml`                           | declared `path = ...` does not exist               |
12//! | `incomplete_pkg`    | `installed.json` + `{pkg_dir}/{name}/init.lua`          | init.lua requires sibling sub (`pkg.sub`) but      |
13//! |                     |                                                         | `sub.lua` / `sub/init.lua` is missing              |
14//!
15//! Contract:
16//! - **No side effects.** No `fs::write`, `fs::remove_*`, `fs::create_*`,
17//!   symlink operations, or `pkg_install`. Filesystem is read-only.
18//! - Reuses [`super::repair`]'s `pub(super)` helpers to keep the classification
19//!   logic authoritative in one place (symlink-dangling suggestion wording and
20//!   the path-missing scan in particular).
21//!
22//! The JSON output schema always contains five top-level buckets:
23//! `healthy`, `incomplete_pkg`, `installed_missing`, `path_missing`,
24//! `symlink_dangling`. Key order within the serialized string follows
25//! `serde_json`'s default (alphabetical when `preserve_order` is off, as it is
26//! in this workspace) — the contract is "these five keys always present", not
27//! textual ordering.
28//!
29//! ## `incomplete_pkg` detection
30//!
31//! Only **static string-literal `require`** calls of the form
32//! `require("pkg_name.sub")` or `require('pkg_name.sub')` are scanned.
33//! Dynamic require forms (`require(variable)`) and non-quoted forms
34//! (`require "foo.bar"`) are **not** detected (MVP scope — false negatives
35//! are acceptable; false positives are not). A future version may use mlua
36//! to perform a real module resolution dry-run.
37
38use std::path::Path;
39
40use tracing::warn;
41
42use super::super::manifest::{load_manifest, Manifest, ManifestEntry};
43use super::super::resolve::packages_dir;
44use super::super::source::PackageSource;
45use super::super::AppService;
46use super::repair::{
47    collect_path_missing, collect_unattached_dangling_symlinks, symlink_dangling_suggestion,
48    ProjectPathSource,
49};
50
51/// Classification of a single manifest-tracked package (read-only).
52#[derive(Debug)]
53enum DoctorOutcome {
54    /// Destination exists and is reachable — no action required.
55    Healthy,
56    /// Destination is a symlink whose target is missing.
57    SymlinkDangling { reason: String, suggestion: String },
58    /// Destination is missing (non-symlink). Install can heal from `source`.
59    InstalledMissing { reason: String, suggestion: String },
60    /// Package directory exists but one or more submodule files required by
61    /// `init.lua` are missing.
62    IncompletePkg {
63        missing_subs: Vec<String>,
64        suggestion: String,
65    },
66}
67
68/// Accumulator for the JSON output buckets.
69///
70/// `narrative_issues` (added in #1778197805) is structurally analogous to the
71/// five status buckets but tagged per-entry with `kind` and `severity` so the
72/// downstream consumer (lint UI, CI gate) can route based on those.
73#[derive(Default)]
74struct DoctorBuckets {
75    healthy: Vec<serde_json::Value>,
76    installed_missing: Vec<serde_json::Value>,
77    symlink_dangling: Vec<serde_json::Value>,
78    path_missing: Vec<serde_json::Value>,
79    incomplete_pkg: Vec<serde_json::Value>,
80    narrative_issues: Vec<serde_json::Value>,
81}
82
83impl DoctorBuckets {
84    fn any_matched(&self) -> bool {
85        !self.healthy.is_empty()
86            || !self.installed_missing.is_empty()
87            || !self.symlink_dangling.is_empty()
88            || !self.path_missing.is_empty()
89            || !self.incomplete_pkg.is_empty()
90            || !self.narrative_issues.is_empty()
91    }
92
93    fn into_json(self) -> String {
94        // All buckets are always emitted (empty arrays when no entries).
95        // `serde_json::json!` serializes keys alphabetically without the
96        // `preserve_order` feature — consumers parse as a Map, not by order.
97        serde_json::json!({
98            "healthy": self.healthy,
99            "incomplete_pkg": self.incomplete_pkg,
100            "installed_missing": self.installed_missing,
101            "narrative_issues": self.narrative_issues,
102            "symlink_dangling": self.symlink_dangling,
103            "path_missing": self.path_missing,
104        })
105        .to_string()
106    }
107}
108
109/// Parse static string-literal `require` calls from Lua source and return the
110/// list of submodule names that belong to `pkg_name`.
111///
112/// Recognized pattern (parenthesised string literal, single or double quote):
113/// ```text
114/// require("pkg_name.sub")
115/// require('pkg_name.sub')
116/// ```
117///
118/// **Not** recognized (MVP scope — false negatives are acceptable):
119/// - `require "foo.bar"` (no parentheses)
120/// - `require(variable)` (dynamic)
121/// - `require([[foo.bar]])` (long-string literal)
122fn extract_required_subs(lua_src: &str, pkg_name: &str) -> Vec<String> {
123    let mut subs = Vec::new();
124    let prefix = format!("{pkg_name}.");
125    let mut remaining = lua_src;
126
127    while let Some(pos) = remaining.find("require") {
128        remaining = &remaining[pos + "require".len()..];
129
130        // Skip whitespace after `require`.
131        let trimmed = remaining.trim_start_matches([' ', '\t']);
132
133        // Must be followed by `(`.
134        if !trimmed.starts_with('(') {
135            continue;
136        }
137        let after_paren = &trimmed[1..];
138        let after_paren = after_paren.trim_start_matches([' ', '\t']);
139
140        // Must be followed by a string quote.
141        let quote = match after_paren.chars().next() {
142            Some(q @ '"') | Some(q @ '\'') => q,
143            _ => continue,
144        };
145        let content = &after_paren[1..];
146        let end = match content.find(quote) {
147            Some(i) => i,
148            None => continue,
149        };
150        let module = &content[..end];
151
152        if let Some(sub) = module.strip_prefix(&prefix) {
153            if !sub.is_empty() && !sub.contains('.') {
154                // Only direct children: `pkg.sub`, not `pkg.sub.deeper`.
155                subs.push(sub.to_string());
156            }
157        }
158    }
159
160    subs.sort();
161    subs.dedup();
162    subs
163}
164
165/// Build the suggestion string for an `incomplete_pkg` entry, branched by
166/// whether the package came from a symlink (link path) or an installed copy.
167fn incomplete_pkg_suggestion(name: &str, is_symlink: bool) -> String {
168    if is_symlink {
169        format!("Re-run alc_pkg_link <path> to re-link {name:?} with the complete source directory")
170    } else {
171        format!(
172            "Run alc_pkg_install --force {name:?} to reinstall {name:?} with all submodule files"
173        )
174    }
175}
176
177/// Suggestion string for `installed_missing`, branched by source kind to
178/// mirror `pkg_repair`'s routing:
179///
180/// - `Git` → `alc_pkg_install(<url>)`
181/// - `Path` → `alc_pkg_install(<path>)` (local re-copy)
182/// - `Bundled` → `alc_init` (bundled packages cannot be reinstalled via
183///   `alc_pkg_install`; they ship inside the algocline binary)
184/// - `Installed` → legacy marker with no re-fetch info (user must re-record
185///   source via `alc_pkg_install`)
186/// - `Unknown` → reindex + reinstall (pre-typed manifest with no source)
187fn installed_missing_suggestion(name: &str, entry_source: &PackageSource) -> String {
188    match entry_source {
189        PackageSource::Bundled { .. } => {
190            "alc_init (reinstalls bundled packages from the algocline binary)".to_string()
191        }
192        PackageSource::Path { path } => {
193            format!("alc_pkg_install({path:?}) to reinstall {name:?} from local path")
194        }
195        PackageSource::Git { url, .. } => {
196            format!("alc_pkg_install({url:?}) to reinstall {name:?} from Git")
197        }
198        PackageSource::Installed => {
199            format!(
200                "alc_pkg_install <path-or-url> to re-record source for {name:?} \
201                 (legacy 'installed' marker carries no path)"
202            )
203        }
204        PackageSource::Unknown => {
205            format!(
206                "alc_hub_reindex then alc_pkg_install <path-or-url> for {name:?} \
207                 (source unknown — legacy entry)"
208            )
209        }
210    }
211}
212
213/// Push a manifest-pass outcome into the appropriate bucket.
214fn push_doctor_outcome(name: &str, outcome: DoctorOutcome, buckets: &mut DoctorBuckets) {
215    match outcome {
216        DoctorOutcome::Healthy => buckets.healthy.push(serde_json::json!({
217            "name": name,
218        })),
219        DoctorOutcome::SymlinkDangling { reason, suggestion } => {
220            buckets.symlink_dangling.push(serde_json::json!({
221                "name": name,
222                "kind": "symlink_dangling",
223                "reason": reason,
224                "suggestion": suggestion,
225            }))
226        }
227        DoctorOutcome::InstalledMissing { reason, suggestion } => {
228            buckets.installed_missing.push(serde_json::json!({
229                "name": name,
230                "kind": "installed_missing",
231                "reason": reason,
232                "suggestion": suggestion,
233            }))
234        }
235        DoctorOutcome::IncompletePkg {
236            missing_subs,
237            suggestion,
238        } => buckets.incomplete_pkg.push(serde_json::json!({
239            "name": name,
240            "kind": "incomplete_pkg",
241            "missing_subs": missing_subs,
242            "suggestion": suggestion,
243        })),
244    }
245}
246
247/// Check whether the package directory at `dest` is incomplete: read
248/// `init.lua`, extract static `require("pkg.sub")` calls, and verify that
249/// each referenced sub-file exists as `{dest}/{sub}.lua` or
250/// `{dest}/{sub}/init.lua`.
251///
252/// Returns `Some(DoctorOutcome::IncompletePkg { .. })` when one or more
253/// submodule files are missing, `None` when everything is present or when
254/// `init.lua` cannot be read (IO errors are logged as warnings and treated as
255/// "no incomplete evidence" rather than propagated — the directory-level
256/// `Healthy` classification already passed and the init.lua read is
257/// best-effort).
258fn check_incomplete(name: &str, dest: &Path, is_symlink: bool) -> Option<DoctorOutcome> {
259    let init_lua = dest.join("init.lua");
260    let src = match std::fs::read_to_string(&init_lua) {
261        Ok(s) => s,
262        Err(e) => {
263            warn!(
264                error = %e,
265                path = %init_lua.display(),
266                "could not read init.lua for incomplete check; skipping"
267            );
268            return None;
269        }
270    };
271
272    let required_subs = extract_required_subs(&src, name);
273    if required_subs.is_empty() {
274        return None;
275    }
276
277    let missing: Vec<String> = required_subs
278        .into_iter()
279        .filter(|sub| {
280            let as_file = dest.join(format!("{sub}.lua"));
281            let as_dir = dest.join(sub).join("init.lua");
282            !as_file.exists() && !as_dir.exists()
283        })
284        .collect();
285
286    if missing.is_empty() {
287        return None;
288    }
289
290    Some(DoctorOutcome::IncompletePkg {
291        missing_subs: missing,
292        suggestion: incomplete_pkg_suggestion(name, is_symlink),
293    })
294}
295
296/// Classify a manifest entry by inspecting only the destination directory.
297/// Mirrors the pre-install branch of [`super::repair::repair_installed`] but
298/// never attempts an install.
299///
300/// After confirming the package directory is reachable, performs an additional
301/// best-effort incomplete check: reads `init.lua` to detect missing sibling
302/// submodule files. See [`check_incomplete`].
303fn classify_installed(name: &str, entry: &ManifestEntry, pkg_dir: &Path) -> DoctorOutcome {
304    let dest = pkg_dir.join(name);
305
306    let is_symlink = dest
307        .symlink_metadata()
308        .map(|m| m.file_type().is_symlink())
309        .unwrap_or(false);
310    if is_symlink {
311        // `try_exists` follows the symlink — true iff target is alive.
312        let target_alive = match dest.try_exists() {
313            Ok(v) => v,
314            Err(e) => {
315                warn!(error = %e, path = %dest.display(), "try_exists failed; treating symlink target as dead");
316                false
317            }
318        };
319        if target_alive {
320            // Symlink alive — check for missing submodule files.
321            if let Some(incomplete) = check_incomplete(name, &dest, true) {
322                return incomplete;
323            }
324            return DoctorOutcome::Healthy;
325        }
326        let link_target = match dest.read_link() {
327            Ok(t) => t.display().to_string(),
328            Err(e) => {
329                warn!(error = %e, path = %dest.display(), "read_link failed; using placeholder for dangling target");
330                "<unknown>".to_string()
331            }
332        };
333        return DoctorOutcome::SymlinkDangling {
334            reason: format!("symlink target missing: {link_target}"),
335            suggestion: symlink_dangling_suggestion(name),
336        };
337    }
338
339    if dest.exists() {
340        // Directory exists — check for missing submodule files.
341        if let Some(incomplete) = check_incomplete(name, &dest, false) {
342            return incomplete;
343        }
344        return DoctorOutcome::Healthy;
345    }
346
347    DoctorOutcome::InstalledMissing {
348        reason: format!("installed directory missing: {}", dest.display()),
349        suggestion: installed_missing_suggestion(name, &entry.source),
350    }
351}
352
353/// Classify every manifest entry into the four buckets. When `target_filter`
354/// is `Some(name)`, look the entry up directly (O(log N) on BTreeMap) instead
355/// of scanning the full map.
356fn run_manifest_pass(
357    manifest: &Manifest,
358    target_filter: Option<&str>,
359    pkg_dir: &Path,
360    buckets: &mut DoctorBuckets,
361) {
362    if let Some(target) = target_filter {
363        if let Some(entry) = manifest.packages.get(target) {
364            let outcome = classify_installed(target, entry, pkg_dir);
365            push_doctor_outcome(target, outcome, buckets);
366        }
367        return;
368    }
369    for (pkg_name, entry) in &manifest.packages {
370        let outcome = classify_installed(pkg_name, entry, pkg_dir);
371        push_doctor_outcome(pkg_name, outcome, buckets);
372    }
373}
374
375/// Drain the unattached-symlink scan results into the `symlink_dangling`
376/// bucket. The shared helper writes tagged entries into a scratch vec so
377/// its signature can stay aligned with `pkg_repair`'s unrepairable bucket.
378fn run_unattached_symlink_pass(
379    pkg_dir: &Path,
380    target_filter: Option<&str>,
381    manifest: &Manifest,
382    buckets: &mut DoctorBuckets,
383) {
384    let mut scratch: Vec<serde_json::Value> = Vec::new();
385    collect_unattached_dangling_symlinks(pkg_dir, target_filter, &manifest.packages, &mut scratch);
386    buckets.symlink_dangling.extend(scratch);
387}
388
389/// Scan `alc.toml` + `alc.local.toml` for declared paths that no longer
390/// resolve. `resolved_root = None` means no project context was located,
391/// which mirrors `pkg_repair`'s skip-on-missing behavior.
392fn run_path_missing_pass(
393    resolved_root: Option<&Path>,
394    target_filter: Option<&str>,
395    buckets: &mut DoctorBuckets,
396) {
397    let Some(root) = resolved_root else {
398        return;
399    };
400    let mut scratch: Vec<serde_json::Value> = Vec::new();
401    collect_path_missing(
402        root,
403        target_filter,
404        "project",
405        &mut scratch,
406        ProjectPathSource::Toml,
407    );
408    collect_path_missing(
409        root,
410        target_filter,
411        "variant",
412        &mut scratch,
413        ProjectPathSource::Local,
414    );
415    buckets.path_missing.extend(scratch);
416}
417
418impl AppService {
419    /// Diagnose package state without any side effects. Returns a JSON string
420    /// with five arrays (`healthy`, `incomplete_pkg`, `installed_missing`,
421    /// `symlink_dangling`, `path_missing`).
422    ///
423    /// `name` restricts the report to a single package; `None` inspects every
424    /// known package. `project_root` is only consulted for the
425    /// `alc.toml` / `alc.local.toml` pass. Falls back to ancestor walk from
426    /// cwd when `None`.
427    ///
428    /// Error surface matches `pkg_repair`:
429    /// - `load_manifest()` / `packages_dir()` failures propagate via `?`.
430    /// - Per-entry `fs::read_dir` errors inside the unattached-symlink scan
431    ///   are logged via `tracing::warn!` and skipped (helper's behavior).
432    /// - `init.lua` read errors during the incomplete check are logged via
433    ///   `tracing::warn!` and skipped (best-effort, no propagation).
434    /// - When `name = Some(target)` and every bucket ends empty, returns
435    ///   `Err` with the same wording used by `pkg_repair`.
436    pub async fn pkg_doctor(
437        &self,
438        name: Option<String>,
439        project_root: Option<String>,
440    ) -> Result<String, String> {
441        let app_dir = self.log_config.app_dir();
442        let manifest = load_manifest(&app_dir)?;
443        let pkg_dir = packages_dir(&app_dir);
444        let resolved_root = self.resolve_root(project_root.as_deref());
445        let target_filter = name.as_deref();
446
447        let mut buckets = DoctorBuckets::default();
448        run_manifest_pass(&manifest, target_filter, &pkg_dir, &mut buckets);
449        run_unattached_symlink_pass(&pkg_dir, target_filter, &manifest, &mut buckets);
450        run_path_missing_pass(resolved_root.as_deref(), target_filter, &mut buckets);
451        self.run_narrative_pass(&pkg_dir, target_filter, &manifest, &mut buckets)
452            .await;
453
454        if let Some(target) = target_filter {
455            if !buckets.any_matched() {
456                return Err(format!(
457                    "Package '{target}' not found in installed.json, ~/.algocline/packages/, alc.toml, or alc.local.toml"
458                ));
459            }
460        }
461
462        Ok(buckets.into_json())
463    }
464
465    /// Narrative SSOT lint pass (#1778197805 L-1).
466    ///
467    /// For each manifest-tracked pkg with a present pkg dir, evaluate
468    /// `M.docs.narrative` declaration vs the actual narrative.md file:
469    ///
470    /// - `declared_missing` (severity `warn`): the pkg declares
471    ///   `M.docs.narrative = "<path>"` but the file is absent. The
472    ///   author either typo'd the path or forgot to install the
473    ///   narrative file alongside `init.lua`.
474    /// - `unmigrated` (severity `info`): the pkg has no `M.docs`
475    ///   declaration but the convention path
476    ///   `<pkg>/narrative.md` does exist. This is the bundled
477    ///   adoption signal — a candidate for the
478    ///   `M.docs = { narrative = "narrative.md" }` migration tracked
479    ///   in #1778197753.
480    ///
481    /// Pkgs without `M.docs` and without a convention narrative.md
482    /// are silently fine — narrative is optional. Pkgs whose
483    /// `M.docs.narrative` resolves to an existing file are silently
484    /// fine — that's the success case.
485    async fn run_narrative_pass(
486        &self,
487        pkg_dir: &Path,
488        target_filter: Option<&str>,
489        manifest: &Manifest,
490        buckets: &mut DoctorBuckets,
491    ) {
492        for (name, _entry) in manifest.packages.iter() {
493            if let Some(target) = target_filter {
494                if target != name.as_str() {
495                    continue;
496                }
497            }
498            let pkg_path = pkg_dir.join(name);
499            if !pkg_path.is_dir() {
500                // Pkg dir absent — covered by other passes; narrative
501                // pass has nothing to say about it.
502                continue;
503            }
504            let declared = match self.pkg_resolve_narrative_path(name).await {
505                Ok(opt) => opt,
506                Err(e) => {
507                    // Lua load failure for this pkg — log and skip.
508                    // The pkg load problem itself surfaces via
509                    // `pkg_list` / other tools; doctor stays narrative-
510                    // focused.
511                    warn!("pkg_doctor narrative pass: pkg '{name}' load failed: {e}");
512                    continue;
513                }
514            };
515            match declared {
516                Some(rel) => {
517                    let narr_path = pkg_path.join(&rel);
518                    if !narr_path.is_file() {
519                        buckets.narrative_issues.push(serde_json::json!({
520                            "name": name,
521                            "kind": "declared_missing",
522                            "severity": "warn",
523                            "declared_path": rel,
524                            "resolved_path": narr_path.to_string_lossy(),
525                            "message": format!(
526                                "M.docs.narrative declares '{rel}' but the file is absent at {}",
527                                narr_path.display()
528                            ),
529                            "suggestion": format!(
530                                "Create the narrative file or update M.docs.narrative to point at an existing file."
531                            ),
532                        }));
533                    }
534                }
535                None => {
536                    let convention = pkg_path.join("narrative.md");
537                    if convention.is_file() {
538                        buckets.narrative_issues.push(serde_json::json!({
539                            "name": name,
540                            "kind": "unmigrated",
541                            "severity": "info",
542                            "resolved_path": convention.to_string_lossy(),
543                            "message": format!(
544                                "convention narrative.md exists but M.docs is not declared (#1778197753 adoption candidate)"
545                            ),
546                            "suggestion": "Add M.docs = { narrative = \"narrative.md\", schema_version = 1 } to init.lua to make the SSOT explicit.".to_string(),
547                        }));
548                    }
549                }
550            }
551        }
552    }
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558    use std::path::PathBuf;
559
560    /// Build a minimal `ManifestEntry` with a `PackageSource::Path`.
561    /// Takes a legacy path string so the existing tests keep reading
562    /// naturally; the arg is wrapped into the typed `Path` variant.
563    fn mk_entry(source: &str) -> ManifestEntry {
564        ManifestEntry {
565            version: None,
566            source: PackageSource::Path {
567                path: source.to_string(),
568            },
569            installed_at: "2026-01-01T00:00:00Z".to_string(),
570            updated_at: "2026-01-01T00:00:00Z".to_string(),
571        }
572    }
573
574    #[test]
575    fn classify_installed_healthy_dir() {
576        let tmp = tempfile::tempdir().unwrap();
577        let pkg_dir = tmp.path();
578        std::fs::create_dir(pkg_dir.join("p")).unwrap();
579
580        let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir);
581        assert!(matches!(outcome, DoctorOutcome::Healthy));
582    }
583
584    #[test]
585    fn classify_installed_missing_dir() {
586        let tmp = tempfile::tempdir().unwrap();
587        let pkg_dir = tmp.path();
588
589        let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir);
590        match outcome {
591            DoctorOutcome::InstalledMissing { reason, suggestion } => {
592                assert!(
593                    reason.contains("installed directory missing"),
594                    "reason = {reason}"
595                );
596                assert!(
597                    suggestion.contains("alc_pkg_install"),
598                    "suggestion = {suggestion}"
599                );
600                assert!(
601                    suggestion.contains("/src/p"),
602                    "suggestion carries source: {suggestion}"
603                );
604            }
605            _ => panic!("expected InstalledMissing"),
606        }
607    }
608
609    #[test]
610    #[cfg(unix)]
611    fn classify_installed_symlink_dangling() {
612        use std::os::unix::fs::symlink;
613
614        let tmp = tempfile::tempdir().unwrap();
615        let pkg_dir = tmp.path();
616        let dangling_target = PathBuf::from("/nonexistent/path/for/doctor_test");
617        symlink(&dangling_target, pkg_dir.join("p")).unwrap();
618
619        let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir);
620        match outcome {
621            DoctorOutcome::SymlinkDangling { reason, suggestion } => {
622                assert!(reason.contains("symlink target missing"), "{reason}");
623                assert!(suggestion.contains("alc_pkg_unlink"), "{suggestion}");
624            }
625            _ => panic!("expected SymlinkDangling"),
626        }
627    }
628
629    #[test]
630    #[cfg(unix)]
631    fn classify_installed_symlink_alive() {
632        use std::os::unix::fs::symlink;
633
634        let tmp = tempfile::tempdir().unwrap();
635        let real_target = tmp.path().join("real_target_dir");
636        std::fs::create_dir(&real_target).unwrap();
637
638        let pkg_dir = tmp.path().join("pkgs");
639        std::fs::create_dir(&pkg_dir).unwrap();
640        symlink(&real_target, pkg_dir.join("q")).unwrap();
641
642        let outcome = classify_installed("q", &mk_entry("/src/q"), &pkg_dir);
643        assert!(matches!(outcome, DoctorOutcome::Healthy));
644    }
645
646    #[test]
647    fn buckets_into_json_emits_all_five_keys() {
648        // NOTE: `serde_json` without the `preserve_order` feature emits JSON
649        // object keys in alphabetical order, matching `pkg_repair`'s actual
650        // behavior. The spec's "fixed order" requirement is satisfied by
651        // always emitting these five top-level keys; consumers parse as a
652        // Map rather than relying on textual key order.
653        let mut b = DoctorBuckets::default();
654        b.healthy.push(serde_json::json!({"name": "h"}));
655        b.installed_missing
656            .push(serde_json::json!({"name": "i", "kind": "installed_missing"}));
657        b.symlink_dangling
658            .push(serde_json::json!({"name": "s", "kind": "symlink_dangling"}));
659        b.path_missing
660            .push(serde_json::json!({"name": "p", "kind": "path_missing"}));
661        b.incomplete_pkg
662            .push(serde_json::json!({"name": "c", "kind": "incomplete_pkg"}));
663
664        let out = b.into_json();
665        let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
666        let obj = parsed.as_object().expect("JSON object");
667        assert!(obj.contains_key("healthy"));
668        assert!(obj.contains_key("installed_missing"));
669        assert!(obj.contains_key("symlink_dangling"));
670        assert!(obj.contains_key("path_missing"));
671        assert!(obj.contains_key("incomplete_pkg"));
672        assert!(obj.contains_key("narrative_issues"));
673        // 6 buckets after #1778197805 (added narrative_issues alongside the
674        // original 5 status buckets).
675        assert_eq!(obj.len(), 6, "exactly six top-level buckets: {out}");
676
677        assert_eq!(obj["healthy"][0]["name"], "h");
678        assert_eq!(obj["installed_missing"][0]["name"], "i");
679        assert_eq!(obj["symlink_dangling"][0]["name"], "s");
680        assert_eq!(obj["path_missing"][0]["name"], "p");
681        assert_eq!(obj["incomplete_pkg"][0]["name"], "c");
682        // narrative_issues defaults to empty array (no entry pushed in this test).
683        assert_eq!(obj["narrative_issues"], serde_json::json!([]));
684    }
685
686    #[test]
687    fn any_matched_tracks_all_buckets() {
688        let mut b = DoctorBuckets::default();
689        assert!(!b.any_matched());
690        b.healthy.push(serde_json::json!({"name": "h"}));
691        assert!(b.any_matched());
692
693        let mut b = DoctorBuckets::default();
694        b.installed_missing.push(serde_json::json!({}));
695        assert!(b.any_matched());
696
697        let mut b = DoctorBuckets::default();
698        b.symlink_dangling.push(serde_json::json!({}));
699        assert!(b.any_matched());
700
701        let mut b = DoctorBuckets::default();
702        b.path_missing.push(serde_json::json!({}));
703        assert!(b.any_matched());
704
705        let mut b = DoctorBuckets::default();
706        b.incomplete_pkg.push(serde_json::json!({}));
707        assert!(b.any_matched());
708    }
709
710    #[test]
711    fn installed_missing_suggestion_shape() {
712        let git = PackageSource::Git {
713            url: "github.com/foo/bar".to_string(),
714            rev: None,
715        };
716        let s = installed_missing_suggestion("ucb", &git);
717        assert!(s.contains("alc_pkg_install"), "{s}");
718        assert!(s.contains("\"ucb\""), "{s}");
719        assert!(s.contains("github.com/foo/bar"), "{s}");
720    }
721
722    /// A bundled-source entry must route the user to `alc_init`, NOT
723    /// `alc_pkg_install("bundled")` (which would fail — bundled packages
724    /// ship inside the algocline binary and are restored via `alc_init`).
725    /// Mirrors `repair.rs` bundled arm.
726    #[test]
727    fn installed_missing_suggestion_routes_bundled_to_alc_init() {
728        let bundled = PackageSource::Bundled { collection: None };
729        let s = installed_missing_suggestion("ucb", &bundled);
730        assert!(s.contains("alc_init"), "bundled must suggest alc_init: {s}");
731        assert!(
732            !s.contains("alc_pkg_install"),
733            "bundled must NOT suggest alc_pkg_install: {s}"
734        );
735    }
736
737    /// A `Path` source entry emits a suggestion pointing at
738    /// `alc_pkg_install(<path>)` — matching repair's LocalPath installer
739    /// route. (Under the typed migration, `alc_pkg_install` now records
740    /// local installs as `Path { path }` rather than the legacy
741    /// `Installed` coercion, so this is the canonical local-reinstall
742    /// suggestion.)
743    #[test]
744    fn installed_missing_suggestion_routes_absolute_path_to_pkg_install() {
745        let local = PackageSource::Path {
746            path: "/abs/path/to/src".to_string(),
747        };
748        let s = installed_missing_suggestion("local_pkg", &local);
749        assert!(s.contains("alc_pkg_install"), "{s}");
750        assert!(s.contains("/abs/path/to/src"), "{s}");
751    }
752
753    /// `Unknown` source (legacy pre-typed entry with no recorded source)
754    /// must route the user to `alc_hub_reindex` before attempting a
755    /// reinstall — mirrors the `Unrepairable` routing in `repair.rs`.
756    #[test]
757    fn installed_missing_suggestion_routes_unknown_to_reindex() {
758        let s = installed_missing_suggestion("legacy_pkg", &PackageSource::Unknown);
759        assert!(
760            s.contains("alc_hub_reindex"),
761            "Unknown must suggest alc_hub_reindex: {s}"
762        );
763    }
764
765    // ── extract_required_subs ────────────────────────────────────────────
766
767    #[test]
768    fn extract_subs_double_quote() {
769        let src = r#"
770local M = {}
771local check = require("mypkg.check")
772local t = require("mypkg.t")
773return M
774"#;
775        let subs = extract_required_subs(src, "mypkg");
776        assert_eq!(subs, vec!["check", "t"]);
777    }
778
779    #[test]
780    fn extract_subs_single_quote() {
781        let src = "local x = require('mypkg.sub')";
782        let subs = extract_required_subs(src, "mypkg");
783        assert_eq!(subs, vec!["sub"]);
784    }
785
786    #[test]
787    fn extract_subs_ignores_other_packages() {
788        let src = r#"
789local x = require("other.sub")
790local y = require("mypkg.mine")
791"#;
792        let subs = extract_required_subs(src, "mypkg");
793        assert_eq!(subs, vec!["mine"]);
794    }
795
796    #[test]
797    fn extract_subs_deduplicates() {
798        let src = r#"
799local a = require("mypkg.check")
800local b = require("mypkg.check")
801"#;
802        let subs = extract_required_subs(src, "mypkg");
803        assert_eq!(subs, vec!["check"]);
804    }
805
806    #[test]
807    fn extract_subs_ignores_dynamic_require() {
808        // Dynamic require (no parenthesised string literal) must not be detected.
809        let src = r#"local x = require(mod_name)"#;
810        let subs = extract_required_subs(src, "mypkg");
811        assert!(subs.is_empty(), "dynamic require must be ignored: {subs:?}");
812    }
813
814    #[test]
815    fn extract_subs_ignores_nested_dots() {
816        // Only direct children: `pkg.sub`, not `pkg.sub.deeper`.
817        let src = r#"local x = require("mypkg.sub.deeper")"#;
818        let subs = extract_required_subs(src, "mypkg");
819        assert!(
820            subs.is_empty(),
821            "nested dotted require must be ignored: {subs:?}"
822        );
823    }
824
825    #[test]
826    fn extract_subs_empty_for_no_require() {
827        let src = r#"local M = {} return M"#;
828        let subs = extract_required_subs(src, "mypkg");
829        assert!(subs.is_empty());
830    }
831
832    // ── check_incomplete ─────────────────────────────────────────────────
833
834    #[test]
835    fn check_incomplete_returns_none_when_all_subs_present_as_lua() {
836        let tmp = tempfile::tempdir().unwrap();
837        let dest = tmp.path().join("mypkg");
838        std::fs::create_dir(&dest).unwrap();
839        std::fs::write(
840            dest.join("init.lua"),
841            r#"local c = require("mypkg.check") return {}"#,
842        )
843        .unwrap();
844        std::fs::write(dest.join("check.lua"), "return {}").unwrap();
845
846        assert!(check_incomplete("mypkg", &dest, false).is_none());
847    }
848
849    #[test]
850    fn check_incomplete_returns_none_when_sub_is_dir_init() {
851        let tmp = tempfile::tempdir().unwrap();
852        let dest = tmp.path().join("mypkg");
853        std::fs::create_dir(&dest).unwrap();
854        std::fs::write(
855            dest.join("init.lua"),
856            r#"local c = require("mypkg.sub") return {}"#,
857        )
858        .unwrap();
859        // sub/ directory with init.lua
860        std::fs::create_dir(dest.join("sub")).unwrap();
861        std::fs::write(dest.join("sub").join("init.lua"), "return {}").unwrap();
862
863        assert!(check_incomplete("mypkg", &dest, false).is_none());
864    }
865
866    #[test]
867    fn check_incomplete_detects_missing_sub() {
868        let tmp = tempfile::tempdir().unwrap();
869        let dest = tmp.path().join("mypkg");
870        std::fs::create_dir(&dest).unwrap();
871        std::fs::write(
872            dest.join("init.lua"),
873            r#"
874local check = require("mypkg.check")
875local t = require("mypkg.t")
876return {}
877"#,
878        )
879        .unwrap();
880        // only `check.lua` present, `t.lua` missing
881        std::fs::write(dest.join("check.lua"), "return {}").unwrap();
882
883        let outcome = check_incomplete("mypkg", &dest, false).expect("should detect incomplete");
884        match outcome {
885            DoctorOutcome::IncompletePkg {
886                missing_subs,
887                suggestion,
888            } => {
889                assert_eq!(missing_subs, vec!["t"], "missing_subs: {missing_subs:?}");
890                assert!(
891                    suggestion.contains("alc_pkg_install"),
892                    "non-symlink suggestion: {suggestion}"
893                );
894            }
895            _ => panic!("expected IncompletePkg"),
896        }
897    }
898
899    #[test]
900    fn check_incomplete_suggestion_uses_link_for_symlink() {
901        let tmp = tempfile::tempdir().unwrap();
902        let dest = tmp.path().join("mypkg");
903        std::fs::create_dir(&dest).unwrap();
904        std::fs::write(
905            dest.join("init.lua"),
906            r#"local x = require("mypkg.missing") return {}"#,
907        )
908        .unwrap();
909        // `missing.lua` absent
910
911        let outcome = check_incomplete("mypkg", &dest, true).expect("should detect incomplete");
912        match outcome {
913            DoctorOutcome::IncompletePkg { suggestion, .. } => {
914                assert!(
915                    suggestion.contains("alc_pkg_link"),
916                    "symlink suggestion: {suggestion}"
917                );
918            }
919            _ => panic!("expected IncompletePkg"),
920        }
921    }
922
923    #[test]
924    fn check_incomplete_returns_none_when_no_init_lua() {
925        // Package with no init.lua at all — best-effort skip, returns None.
926        let tmp = tempfile::tempdir().unwrap();
927        let dest = tmp.path().join("mypkg");
928        std::fs::create_dir(&dest).unwrap();
929
930        assert!(check_incomplete("mypkg", &dest, false).is_none());
931    }
932
933    #[test]
934    fn classify_installed_incomplete_pkg() {
935        // classify_installed should return IncompletePkg when sub.lua is missing.
936        let tmp = tempfile::tempdir().unwrap();
937        let pkg_dir = tmp.path();
938        let dest = pkg_dir.join("mypkg");
939        std::fs::create_dir(&dest).unwrap();
940        std::fs::write(
941            dest.join("init.lua"),
942            r#"local x = require("mypkg.sub") return {}"#,
943        )
944        .unwrap();
945        // sub.lua intentionally absent
946
947        let outcome = classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir);
948        match outcome {
949            DoctorOutcome::IncompletePkg {
950                missing_subs,
951                suggestion,
952            } => {
953                assert_eq!(missing_subs, vec!["sub"]);
954                assert!(suggestion.contains("alc_pkg_install"), "{suggestion}");
955            }
956            _ => panic!("expected IncompletePkg, got {outcome:?}"),
957        }
958    }
959
960    #[test]
961    fn classify_installed_healthy_when_all_subs_present() {
962        // classify_installed should return Healthy when all required subs exist.
963        let tmp = tempfile::tempdir().unwrap();
964        let pkg_dir = tmp.path();
965        let dest = pkg_dir.join("mypkg");
966        std::fs::create_dir(&dest).unwrap();
967        std::fs::write(
968            dest.join("init.lua"),
969            r#"local x = require("mypkg.sub") return {}"#,
970        )
971        .unwrap();
972        std::fs::write(dest.join("sub.lua"), "return {}").unwrap();
973
974        let outcome = classify_installed("mypkg", &mk_entry("/src/mypkg"), pkg_dir);
975        assert!(
976            matches!(outcome, DoctorOutcome::Healthy),
977            "expected Healthy, got {outcome:?}"
978        );
979    }
980}