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