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