Skip to main content

algocline_app/service/pkg/
repair.rs

1//! `pkg_repair` — heal broken package state (Wave 2 of local-first DX).
2//!
3//! Scope (decisions.md Q3, issue.md `G2 stale link 修復`):
4//!
5//! | Broken kind | Source-of-truth | Repair? |
6//! |---|---|---|
7//! | (B) installed dir missing (manifest entry exists) | `installed.json.source` | ✓ via `pkg_install` |
8//! | (A) global symlink dangling | none (`pkg_link` doesn't write manifest) | ✗ |
9//! | (C) `alc.toml` `path = ...` missing | user-authored path | ✗ |
10//! | (D) `alc.local.toml` `path = ...` missing | user-authored path | ✗ |
11//!
12//! `alc_pkg_repair` is an actuator (side-effecting). The sensor side
13//! (`alc_pkg_list`) is intentionally read-only — see decisions.md Q3.
14
15use std::collections::HashSet;
16use std::path::{Path, PathBuf};
17
18use super::super::alc_toml::{self, PackageDep};
19use super::super::lockfile::load_lockfile;
20use super::super::manifest::{load_manifest, ManifestEntry};
21use super::super::resolve::{packages_dir, LUA_TYPE_AUTODETECT};
22use super::super::source::PackageSource;
23use super::super::AppService;
24use super::install::InstallSource;
25
26/// Outcome of repairing a single manifest-tracked package.
27enum RepairOutcome {
28    /// Successfully reinstalled from `source`.
29    Repaired { source: String },
30    /// Package is healthy — nothing to do.
31    Skipped,
32    /// Cannot repair automatically — user must intervene. `kind` is emitted
33    /// verbatim into the JSON bucket entry, letting a single variant carry
34    /// both the `installed_missing` sub-kinds (bundled / path) and the
35    /// `symlink_dangling` case (dangling symlink at a manifest-tracked name).
36    Unrepairable {
37        kind: &'static str,
38        reason: String,
39        suggestion: String,
40    },
41    /// Repair was attempted but failed.
42    Failed { reason: String },
43}
44
45/// Accumulator for the four JSON output buckets.
46#[derive(Default)]
47struct Buckets {
48    repaired: Vec<serde_json::Value>,
49    skipped: Vec<serde_json::Value>,
50    unrepairable: Vec<serde_json::Value>,
51    failed: Vec<serde_json::Value>,
52}
53
54impl Buckets {
55    fn any_matched(&self) -> bool {
56        !self.repaired.is_empty()
57            || !self.skipped.is_empty()
58            || !self.unrepairable.is_empty()
59            || !self.failed.is_empty()
60    }
61
62    fn into_json(self) -> String {
63        serde_json::json!({
64            "repaired": self.repaired,
65            "skipped": self.skipped,
66            "unrepairable": self.unrepairable,
67            "failed": self.failed,
68        })
69        .to_string()
70    }
71}
72
73/// Suggestion string shared by the manifest-pass dangling-symlink case and
74/// the (A) unattached-symlink pass.
75pub(super) fn symlink_dangling_suggestion(name: &str) -> String {
76    format!("alc_pkg_unlink({name:?}) then alc_pkg_link with the new path")
77}
78
79/// Routing bucket for alive-symlink entries detected by
80/// `collect_alive_unregistered_symlinks`. The JSON shape is built by
81/// `run_alive_unregistered_symlink_pass` in `doctor.rs`.
82#[derive(Debug, PartialEq, Eq)]
83pub(super) enum AliveBucket {
84    /// `type_source == "auto_detected_library"` from the Lua VM eval —
85    /// no explicit `M.meta.type` in init.lua; routes to the `unmarked_library`
86    /// doctor bucket.
87    UnmarkedLibrary,
88    /// All other entries (explicit type, auto-detected runnable, eval failure,
89    /// or type_source absent) — routes to `unregistered_pkg`.
90    Unregistered,
91}
92
93/// Push a manifest-pass outcome into the appropriate bucket. Non-Unrepairable
94/// outcomes use `kind = "installed_missing"`; Unrepairable carries its own
95/// kind so both `installed_missing` (bundled/path) and `symlink_dangling`
96/// can flow through the same helper.
97fn push_installed_outcome(name: &str, outcome: RepairOutcome, buckets: &mut Buckets) {
98    match outcome {
99        RepairOutcome::Repaired { source } => buckets.repaired.push(serde_json::json!({
100            "name": name,
101            "kind": "installed_missing",
102            "action": "reinstall",
103            "source": source,
104        })),
105        RepairOutcome::Skipped => buckets.skipped.push(serde_json::json!({
106            "name": name,
107            "reason": "healthy",
108        })),
109        RepairOutcome::Unrepairable {
110            kind,
111            reason,
112            suggestion,
113        } => buckets.unrepairable.push(serde_json::json!({
114            "name": name,
115            "kind": kind,
116            "reason": reason,
117            "suggestion": suggestion,
118        })),
119        RepairOutcome::Failed { reason } => buckets.failed.push(serde_json::json!({
120            "name": name,
121            "kind": "installed_missing",
122            "reason": reason,
123        })),
124    }
125}
126
127impl AppService {
128    /// Heal broken packages by re-installing from `installed.json` source.
129    ///
130    /// `name` — restrict to a single package; `None` repairs every broken pkg.
131    /// `project_root` — used for project / variant pkg path checks. Falls back
132    /// to ancestor walk from cwd.
133    ///
134    /// Returns JSON with `repaired`, `skipped`, `unrepairable`, `failed`
135    /// arrays (each entry has `name` + per-bucket fields). Repair is
136    /// best-effort: the per-pkg result is reported regardless of outcome.
137    pub async fn pkg_repair(
138        &self,
139        name: Option<String>,
140        project_root: Option<String>,
141    ) -> Result<String, String> {
142        let app_dir = self.log_config.app_dir();
143        let manifest = load_manifest(&app_dir)?;
144        let pkg_dir = packages_dir(&app_dir);
145        let resolved_root = self.resolve_root(project_root.as_deref());
146
147        let mut buckets = Buckets::default();
148        let target_filter = name.as_deref();
149
150        // ── (B) installed pkgs from manifest ──────────────────────
151        for (pkg_name, entry) in &manifest.packages {
152            if let Some(target) = target_filter {
153                if target != pkg_name.as_str() {
154                    continue;
155                }
156            }
157            let outcome = self.repair_installed(pkg_name, entry, &pkg_dir).await;
158            push_installed_outcome(pkg_name, outcome, &mut buckets);
159        }
160
161        // ── (A) unattached dangling symlinks (no manifest entry) ──
162        collect_unattached_dangling_symlinks(
163            &pkg_dir,
164            target_filter,
165            &manifest.packages,
166            &mut buckets.unrepairable,
167        );
168
169        // ── (C) project `path = ...` missing ──────────────────────
170        // ── (D) variant `path = ...` missing ──────────────────────
171        if let Some(root) = resolved_root.as_ref() {
172            collect_path_missing(
173                root,
174                target_filter,
175                "project",
176                &mut buckets.unrepairable,
177                ProjectPathSource::Toml,
178            );
179            collect_path_missing(
180                root,
181                target_filter,
182                "variant",
183                &mut buckets.unrepairable,
184                ProjectPathSource::Local,
185            );
186        }
187
188        if let Some(target) = target_filter {
189            if !buckets.any_matched() {
190                return Err(format!(
191                    "Package '{target}' not found in installed.json, ~/.algocline/packages/, alc.toml, or alc.local.toml"
192                ));
193            }
194        }
195
196        Ok(buckets.into_json())
197    }
198
199    /// Attempt to repair a single manifest-tracked package by re-running
200    /// `pkg_install` with the recorded `source`. Returns `Skipped` when the
201    /// package directory already exists (healthy), or Unrepairable with
202    /// `kind = "symlink_dangling"` when dest is a dangling symlink — the
203    /// (A) pass's "skip if in manifest" rule would otherwise drop this case.
204    async fn repair_installed(
205        &self,
206        name: &str,
207        entry: &ManifestEntry,
208        pkg_dir: &Path,
209    ) -> RepairOutcome {
210        let dest = pkg_dir.join(name);
211
212        let is_symlink = dest
213            .symlink_metadata()
214            .map(|m| m.file_type().is_symlink())
215            .unwrap_or(false);
216        if is_symlink {
217            // `try_exists` follows the symlink — true iff target is alive.
218            let target_alive = dest.try_exists().unwrap_or(false);
219            if target_alive {
220                return RepairOutcome::Skipped;
221            }
222            let link_target = dest
223                .read_link()
224                .map(|t| t.display().to_string())
225                .unwrap_or_else(|_| "<unknown>".to_string());
226            return RepairOutcome::Unrepairable {
227                kind: "symlink_dangling",
228                reason: format!("symlink target missing: {link_target}"),
229                suggestion: symlink_dangling_suggestion(name),
230            };
231        }
232
233        if dest.exists() {
234            return RepairOutcome::Skipped;
235        }
236
237        // Source classification: only `Path` (local copy) and `Git` can be
238        // re-fetched. Bundled is conceptually re-installable via `alc_init`;
239        // `Installed` is a legacy marker that carries no re-fetch info (the
240        // typed successor is `Path { path }`). `Unknown` is the pre-typed
241        // "source unrecorded" landing site and is structurally unrepairable.
242        //
243        // States detectable before attempting install belong in `unrepairable`,
244        // not `failed`. `failed` is reserved for runtime errors during an
245        // actual install attempt.
246        let install_source = match &entry.source {
247            PackageSource::Path { path } => InstallSource::LocalPath(PathBuf::from(path)),
248            PackageSource::Git { url, .. } => InstallSource::GitUrl(normalize_git_url(url)),
249            PackageSource::Bundled { .. } => {
250                return RepairOutcome::Unrepairable {
251                    kind: "installed_missing",
252                    reason: "bundled package — restore via `alc_init` or reinstall algocline"
253                        .to_string(),
254                    suggestion: "alc_init (reinstalls bundled packages from the algocline binary)"
255                        .to_string(),
256                };
257            }
258            PackageSource::Installed => {
259                // Legacy marker: pre-typed manifest that recorded a local install
260                // as `source: "installed"` / absolute path (see
261                // `infer_from_legacy_source_string`). The actual source path is
262                // lost, so we cannot re-fetch automatically.
263                return RepairOutcome::Unrepairable {
264                    kind: "installed_missing",
265                    reason: "legacy 'installed' marker carries no source path".to_string(),
266                    suggestion: "alc_pkg_install <path-or-url> to re-record source, \
267                                 then alc_pkg_repair"
268                        .to_string(),
269                };
270            }
271            PackageSource::Unknown => {
272                // Pre-typed manifest entry with `source: ""` (never recorded).
273                // Routed here per the Phase 3 spec: `Unknown` must land in
274                // `Unrepairable`, not be silently coerced.
275                return RepairOutcome::Unrepairable {
276                    kind: "installed_missing",
277                    reason: "source unknown (legacy entry; run alc_hub_reindex)".to_string(),
278                    suggestion: "alc_hub_reindex to rebuild the index, or \
279                                 alc_pkg_install <path-or-url> to re-record source"
280                        .to_string(),
281                };
282            }
283        };
284
285        // Pre-check: a LocalPath is structurally unrepairable when
286        // (a) the source directory no longer exists, or
287        // (b) the source exists but the named package's subdirectory
288        //     (`<source>/<name>/init.lua`) is absent.
289        //
290        // Since Single-package mode was removed in v0.36.0, all local installs
291        // use collection layout: the recorded source is the collection root and
292        // each package lives at `<source>/<name>/init.lua`.  We check for the
293        // named package's own init.lua rather than a root-level one.
294        //
295        // Git sources are deliberately not pre-checked here: network/remote
296        // availability is a runtime concern that belongs in the attempt path.
297        if let InstallSource::LocalPath(ref p) = install_source {
298            if !p.exists() {
299                return RepairOutcome::Unrepairable {
300                    kind: "installed_missing",
301                    reason: format!("source directory missing: {}", p.display()),
302                    suggestion: format!(
303                        "alc_pkg_install from a valid source, or remove the '{name}' entry from ~/.algocline/installed.json"
304                    ),
305                };
306            }
307            if !p.join(name).join("init.lua").exists() {
308                return RepairOutcome::Unrepairable {
309                    kind: "installed_missing",
310                    reason: format!(
311                        "source directory has no init.lua at root: {}",
312                        p.display()
313                    ),
314                    suggestion: format!(
315                        "alc_pkg_install from a valid source, or remove the '{name}' entry from ~/.algocline/installed.json"
316                    ),
317                };
318            }
319        }
320
321        // Re-install from the collection root.  Single-package mode was removed
322        // in v0.36.0, so `name` is not passed; `install_from_local_path` will
323        // scan `<source>/<name>/init.lua` and reinstall all packages found in
324        // the collection.  For the common case of a 1-entry collection this is
325        // equivalent to targeted reinstall.
326        match self.pkg_install_typed(install_source, None, None).await {
327            Ok(_) => RepairOutcome::Repaired {
328                // Emit a human-readable source string (legacy schema). The
329                // typed source is already persisted back into the manifest
330                // by the install path — this field is just display.
331                source: entry.source.display_string(),
332            },
333            Err(e) => RepairOutcome::Failed { reason: e },
334        }
335    }
336}
337
338/// Apply the same URL scheme normalization `classify_install_url` uses
339/// without re-checking whether the string refers to a local directory.
340/// Repair has already established the source is Git (typed
341/// `PackageSource::Git`); re-classifying via the directory heuristic would
342/// be both redundant and racy. Delegates to the shared
343/// [`super::install::prefix_git_scheme_if_missing`] helper so that install
344/// and repair stay in lockstep on scheme handling.
345fn normalize_git_url(url: &str) -> String {
346    super::install::prefix_git_scheme_if_missing(url)
347}
348
349/// Scan `pkg_dir` for dangling symlinks whose name is *not* present in the
350/// manifest. Manifest-tracked names are handled by `repair_installed` so
351/// they're skipped here to avoid double-counting.
352pub(super) fn collect_unattached_dangling_symlinks(
353    pkg_dir: &Path,
354    target_filter: Option<&str>,
355    manifest_names: &std::collections::BTreeMap<String, ManifestEntry>,
356    unrepairable: &mut Vec<serde_json::Value>,
357) {
358    let read = match std::fs::read_dir(pkg_dir) {
359        Ok(r) => r,
360        Err(e) => {
361            tracing::warn!(
362                "pkg: failed to read packages_dir at {}: {e}",
363                pkg_dir.display()
364            );
365            return;
366        }
367    };
368
369    for dir_entry_result in read {
370        let dir_entry = match dir_entry_result {
371            Ok(e) => e,
372            Err(e) => {
373                // Previously this scan used `read.flatten()` which dropped
374                // per-entry I/O errors silently. Some names (permission
375                // denials, transient FS errors) therefore slipped through
376                // the dangling-symlink check without diagnosis. Log here
377                // so at least the repair attempt leaves a trail.
378                tracing::warn!(
379                    "pkg: skipping unreadable entry in {}: {e}",
380                    pkg_dir.display()
381                );
382                continue;
383            }
384        };
385        let path = dir_entry.path();
386        let pkg_name = dir_entry.file_name().to_string_lossy().to_string();
387
388        if let Some(target) = target_filter {
389            if target != pkg_name.as_str() {
390                continue;
391            }
392        }
393        if manifest_names.contains_key(&pkg_name) {
394            continue;
395        }
396
397        let is_symlink = path
398            .symlink_metadata()
399            .map(|m| m.file_type().is_symlink())
400            .unwrap_or(false);
401        if !is_symlink {
402            continue;
403        }
404        let target_exists = path.try_exists().unwrap_or(false);
405        if target_exists {
406            continue;
407        }
408
409        let link_target = path
410            .read_link()
411            .map(|t| t.display().to_string())
412            .unwrap_or_else(|_| "<unknown>".to_string());
413
414        unrepairable.push(serde_json::json!({
415            "name": pkg_name,
416            "kind": "symlink_dangling",
417            "reason": format!("symlink target missing: {link_target}"),
418            "suggestion": symlink_dangling_suggestion(&pkg_name),
419        }));
420    }
421}
422
423/// Which TOML file is the source of truth for path entries.
424#[derive(Debug, Clone, Copy)]
425pub(super) enum ProjectPathSource {
426    /// `alc.toml` `[packages.x] path = ...` (project scope).
427    Toml,
428    /// `alc.local.toml` `[packages.x] path = ...` (variant scope).
429    Local,
430}
431
432/// Append `path_missing` unrepairable entries for either alc.toml or
433/// alc.local.toml. Filtering by `target_filter` (Some(name)) restricts
434/// to a single package.
435pub(super) fn collect_path_missing(
436    root: &Path,
437    target_filter: Option<&str>,
438    scope: &'static str,
439    unrepairable: &mut Vec<serde_json::Value>,
440    src: ProjectPathSource,
441) {
442    let loaded = match src {
443        ProjectPathSource::Toml => alc_toml::load_alc_toml(root),
444        ProjectPathSource::Local => alc_toml::load_alc_local_toml(root),
445    };
446    let Ok(Some(toml_data)) = loaded else {
447        return;
448    };
449
450    // For project scope, the lockfile is the more accurate source for the
451    // resolved path (it absorbs canonicalization done at install time). Fall
452    // back to the alc.toml declaration when no lockfile exists.
453    //
454    // TODO(variant-canonicalization): variant scope reads the raw
455    // alc.local.toml path verbatim. If `pkg_link --scope=variant` ever starts
456    // writing relative paths (today it writes absolute), this block will
457    // diverge from what `pkg_list` / `pkg_run` resolve — mirror the project
458    // lockfile lookup for variants at that point.
459    let lock_lookup = if matches!(src, ProjectPathSource::Toml) {
460        load_lockfile(root).ok().flatten().map(|l| {
461            l.packages
462                .into_iter()
463                .filter_map(|p| match p.source {
464                    PackageSource::Path { path } => Some((p.name, path)),
465                    _ => None,
466                })
467                .collect::<std::collections::HashMap<String, String>>()
468        })
469    } else {
470        None
471    };
472
473    for (name, dep) in &toml_data.packages {
474        if let Some(t) = target_filter {
475            if t != name.as_str() {
476                continue;
477            }
478        }
479
480        let raw = match dep {
481            PackageDep::Path { path, .. } => path,
482            _ => continue,
483        };
484
485        let resolved_raw = lock_lookup
486            .as_ref()
487            .and_then(|m| m.get(name).cloned())
488            .unwrap_or_else(|| raw.clone());
489
490        let p = Path::new(&resolved_raw);
491        let abs = if p.is_absolute() {
492            p.to_path_buf()
493        } else {
494            root.join(p)
495        };
496
497        if abs.exists() {
498            continue;
499        }
500
501        let suggestion = match src {
502            ProjectPathSource::Toml => {
503                format!("update or remove [packages.{name}] in alc.toml")
504            }
505            ProjectPathSource::Local => {
506                format!("alc_pkg_unlink({name:?}) or update [packages.{name}] in alc.local.toml")
507            }
508        };
509
510        unrepairable.push(serde_json::json!({
511            "name": name,
512            "kind": "path_missing",
513            "scope": scope,
514            "reason": format!("declared path does not exist: {}", abs.display()),
515            "suggestion": suggestion,
516        }));
517    }
518}
519
520/// Walk `pkg_dir` and collect physical directories that contain `init.lua` but
521/// are not registered in any of the three authoritative sources:
522/// `installed.json` (manifest), `alc.toml [packages]`, or
523/// `alc.local.toml [packages]`.
524///
525/// # Arguments
526///
527/// * `pkg_dir` — `~/.algocline/packages/` (or the path under test)
528/// * `registered` — set of package names known to any registration source
529/// * `registered_paths` — canonicalized absolute paths declared in
530///   `[packages.x] path = "..."` entries from alc.toml / alc.local.toml; used
531///   to skip false positives where a path-dep points inside `pkg_dir`
532/// * `target_filter` — when `Some(name)`, restrict output to that single name
533///
534/// # Returns
535///
536/// A `Vec<serde_json::Value>` of `unregistered_pkg` bucket entries on success.
537/// Each entry carries `name`, `kind`, `source`, `reason`, and `suggestion`
538/// (array of four strings, Clippy-style multi-line).
539///
540/// # Errors
541///
542/// Returns `Err(String)` if `pkg_dir` exists but cannot be read (any `io::Error`
543/// other than `NotFound`). `NotFound` is treated as empty (no packages installed)
544/// and returns `Ok(vec![])`.
545pub(super) fn collect_unregistered_pkg_dirs(
546    pkg_dir: &Path,
547    registered: &HashSet<String>,
548    registered_paths: &[PathBuf],
549    target_filter: Option<&str>,
550) -> Result<Vec<serde_json::Value>, String> {
551    let read = match std::fs::read_dir(pkg_dir) {
552        Ok(r) => r,
553        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
554            // packages_dir absent == empty, not an error (file absent === empty).
555            return Ok(vec![]);
556        }
557        Err(e) => {
558            return Err(format!(
559                "pkg: failed to read packages_dir at {}: {e}",
560                pkg_dir.display()
561            ));
562        }
563    };
564
565    let mut entries = Vec::new();
566
567    for dir_entry_result in read {
568        let dir_entry = match dir_entry_result {
569            Ok(e) => e,
570            Err(e) => {
571                tracing::warn!(
572                    "pkg: skipping unreadable entry in {}: {e}",
573                    pkg_dir.display()
574                );
575                continue;
576            }
577        };
578
579        let path = dir_entry.path();
580        let pkg_name = dir_entry.file_name().to_string_lossy().to_string();
581
582        // When a specific target is requested, skip all others.
583        if let Some(target) = target_filter {
584            if target != pkg_name.as_str() {
585                continue;
586            }
587        }
588
589        // Skip if name is already in one of the three registration sources.
590        if registered.contains(&pkg_name) {
591            continue;
592        }
593
594        // Only physical directories with init.lua qualify.
595        let meta = match path.symlink_metadata() {
596            Ok(m) => m,
597            Err(e) => {
598                tracing::warn!("pkg: cannot stat {}: {e}", path.display());
599                continue;
600            }
601        };
602        if !meta.is_dir() {
603            // Symlinks are handled by run_unattached_symlink_pass; skip here.
604            continue;
605        }
606        if !path.join("init.lua").exists() {
607            // Empty or non-package directory — skip (AC-2).
608            continue;
609        }
610
611        // Canonical path comparison: skip if any alc.toml / alc.local.toml
612        // path entry resolves to the same physical directory (AC-4).
613        let canonical_pkg_path = match path.canonicalize() {
614            Ok(c) => c,
615            Err(e) => {
616                return Err(format!(
617                    "pkg: failed to canonicalize existing dir {}: {e}",
618                    path.display()
619                ));
620            }
621        };
622        if registered_paths.contains(&canonical_pkg_path) {
623            continue;
624        }
625
626        // Build Clippy-style multi-line suggestion (crux constraint: 4 elements,
627        // suggestion field is array<string> for unregistered_pkg only).
628        let abs_path = path.display().to_string();
629        let suggestion = serde_json::json!([
630            format!(
631                "If this pkg was scaffolded outside `alc_pkg_scaffold` and you want it installed: \
632                `alc_pkg_install --force {abs_path}` (re-copy + register in installed.json)"
633            ),
634            format!(
635                "If you are actively iterating on this pkg in-tree: \
636                `alc_pkg_link {abs_path}` (symlink-based, no copy)"
637            ),
638            format!("If this dir is stale/abandoned: `rm -rf {abs_path}` to clean it up"),
639            "Note: source is unknown — git URL cannot be inferred from the bare directory. \
640            Re-record via one of the above."
641                .to_string(),
642        ]);
643
644        entries.push(serde_json::json!({
645            "name": pkg_name,
646            "kind": "unregistered_pkg",
647            "source": "unknown",
648            "reason": format!(
649                "physical dir with init.lua exists but is not registered in \
650                installed.json, alc.toml, or alc.local.toml: {}",
651                path.display()
652            ),
653            "suggestion": suggestion,
654        }));
655    }
656
657    Ok(entries)
658}
659
660/// Returns `true` iff `name` is safe to interpolate into a Lua `require()` call.
661///
662/// Accepts ASCII alphanumerics, `_` and `-`. Empty strings are rejected.
663/// Mirrors the implementation in `list.rs` (which also allows `-` for hyphenated
664/// package names such as `crdt-doc`).
665fn is_safe_pkg_name(name: &str) -> bool {
666    !name.is_empty()
667        && name
668            .bytes()
669            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
670}
671
672impl AppService {
673    /// Walk `pkg_dir` and collect **alive symlinks** that contain `init.lua` at the
674    /// resolved target but are not registered in any of the three authoritative
675    /// sources: `installed.json` (manifest), `alc.toml [packages]`, or
676    /// `alc.local.toml [packages]`.
677    ///
678    /// Unlike `collect_unregistered_pkg_dirs` (physical dirs only) and
679    /// `collect_unattached_dangling_symlinks` (dangling symlinks only), this
680    /// helper exclusively handles alive symlinks — those where the link target
681    /// exists and is reachable via `path.try_exists()`.
682    ///
683    /// Each qualifying entry is classified into an [`AliveBucket`] by executing
684    /// the `LUA_TYPE_AUTODETECT` snippet via `eval_simple_with_paths` — the same
685    /// runtime path used by `pkg_list`. The `type_source` field in the returned
686    /// meta determines the bucket:
687    ///
688    /// - `"auto_detected_library"` → [`AliveBucket::UnmarkedLibrary`]
689    /// - All other values (explicit, auto_detected_runnable, eval failure, or
690    ///   absent) → [`AliveBucket::Unregistered`]
691    ///
692    /// JSON shape construction is deferred to `run_alive_unregistered_symlink_pass`
693    /// in `doctor.rs`; this method is detection-only.
694    ///
695    /// # Arguments
696    ///
697    /// * `pkg_dir` — `~/.algocline/packages/` (or the path under test)
698    /// * `registered` — set of package names known to any registration source
699    /// * `registered_paths` — canonicalized absolute paths declared in
700    ///   `[packages.x] path = "..."` entries from alc.toml / alc.local.toml; used
701    ///   to skip false positives where a path-dep symlink resolves to a registered dir
702    /// * `target_filter` — when `Some(name)`, restrict output to that single name
703    ///
704    /// # Returns
705    ///
706    /// A `Vec<(String, AliveBucket)>` of `(pkg_name, bucket)` pairs on success.
707    ///
708    /// # Errors
709    ///
710    /// Returns `Err(String)` if `pkg_dir` exists but cannot be read (any `io::Error`
711    /// other than `NotFound`). `NotFound` is treated as empty (no packages installed)
712    /// and returns `Ok(vec![])`. Individual entry stat or eval failures emit a
713    /// `tracing::warn!` and continue. `canonicalize` failure returns `Err`.
714    pub(super) async fn collect_alive_unregistered_symlinks(
715        &self,
716        pkg_dir: &Path,
717        registered: &HashSet<String>,
718        registered_paths: &[PathBuf],
719        target_filter: Option<&str>,
720    ) -> Result<Vec<(String, AliveBucket)>, String> {
721        let read = match std::fs::read_dir(pkg_dir) {
722            Ok(r) => r,
723            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
724                // packages_dir absent == empty, not an error (file absent === empty).
725                return Ok(vec![]);
726            }
727            Err(e) => {
728                return Err(format!(
729                    "pkg: failed to read packages_dir at {}: {e}",
730                    pkg_dir.display()
731                ));
732            }
733        };
734
735        let mut entries = Vec::new();
736
737        for dir_entry_result in read {
738            let dir_entry = match dir_entry_result {
739                Ok(e) => e,
740                Err(e) => {
741                    tracing::warn!(
742                        "pkg: skipping unreadable entry in {}: {e}",
743                        pkg_dir.display()
744                    );
745                    continue;
746                }
747            };
748
749            let path = dir_entry.path();
750            let pkg_name = dir_entry.file_name().to_string_lossy().to_string();
751
752            // When a specific target is requested, skip all others.
753            if let Some(target) = target_filter {
754                if target != pkg_name.as_str() {
755                    continue;
756                }
757            }
758
759            // Skip if name is already in one of the three registration sources.
760            if registered.contains(&pkg_name) {
761                continue;
762            }
763
764            // Only symlinks qualify for this pass.
765            let meta = match path.symlink_metadata() {
766                Ok(m) => m,
767                Err(e) => {
768                    tracing::warn!("pkg: cannot stat {}: {e}", path.display());
769                    continue;
770                }
771            };
772            if !meta.file_type().is_symlink() {
773                // Physical dirs are handled by collect_unregistered_pkg_dirs; skip here.
774                continue;
775            }
776
777            // Only alive symlinks — dangling ones belong to collect_unattached_dangling_symlinks.
778            // `try_exists` follows the link: true iff target is reachable.
779            let target_exists = path.try_exists().unwrap_or(false);
780            if !target_exists {
781                continue;
782            }
783
784            // Link target must have an init.lua (follow through symlink via std::fs::exists).
785            if !path.join("init.lua").exists() {
786                continue;
787            }
788
789            // Canonical path comparison: skip if any alc.toml / alc.local.toml
790            // path entry resolves to the same physical directory (false-positive guard).
791            // `canonicalize` follows the symlink and returns the link target's absolute path.
792            let canonical_pkg_path = match path.canonicalize() {
793                Ok(c) => c,
794                Err(e) => {
795                    return Err(format!(
796                        "pkg: failed to canonicalize symlink target {}: {e}",
797                        path.display()
798                    ));
799                }
800            };
801            if registered_paths.contains(&canonical_pkg_path) {
802                continue;
803            }
804
805            // Classify via eval_simple + LUA_TYPE_AUTODETECT — same runtime path as
806            // pkg_list. pkg_dir is passed as an extra lib path so require() resolves
807            // the package in both production (~/.algocline/packages/) and tests.
808            let bucket = if is_safe_pkg_name(&pkg_name) {
809                let code = format!(
810                    r#"package.loaded["{pkg_name}"] = nil
811local pkg = require("{pkg_name}")
812local meta = pkg.meta or {{ name = "{pkg_name}" }}
813{LUA_TYPE_AUTODETECT}
814return meta"#,
815                    pkg_name = pkg_name,
816                    LUA_TYPE_AUTODETECT = LUA_TYPE_AUTODETECT,
817                );
818                match self
819                    .executor
820                    .eval_simple_with_paths(code, vec![pkg_dir.to_path_buf()], vec![])
821                    .await
822                {
823                    Ok(meta) => {
824                        if meta.get("type_source").and_then(|v| v.as_str())
825                            == Some("auto_detected_library")
826                        {
827                            AliveBucket::UnmarkedLibrary
828                        } else {
829                            AliveBucket::Unregistered
830                        }
831                    }
832                    Err(e) => {
833                        tracing::warn!(
834                            "pkg: alive-symlink type_source eval failed for {pkg_name}: {e}"
835                        );
836                        AliveBucket::Unregistered
837                    }
838                }
839            } else {
840                // Unsafe name cannot be interpolated into require() — skip eval,
841                // fall back to Unregistered.
842                AliveBucket::Unregistered
843            };
844
845            entries.push((pkg_name, bucket));
846        }
847
848        Ok(entries)
849    }
850}
851
852#[cfg(test)]
853mod tests {
854    use super::*;
855
856    #[cfg(unix)]
857    mod alive_symlink_tests {
858        use super::super::super::super::test_support::make_app_service_at;
859        use super::*;
860        use std::os::unix::fs::symlink as unix_symlink;
861
862        /// Write a minimal init.lua with `M.meta.name` but no explicit type and no
863        /// `M.run` function — `LUA_TYPE_AUTODETECT` will classify this as
864        /// `type_source = "auto_detected_library"`.
865        fn write_auto_library_init_lua(pkg_dir: &std::path::Path, pkg_name: &str) {
866            let pkg = pkg_dir.join(pkg_name);
867            std::fs::create_dir_all(&pkg).expect("create pkg dir");
868            std::fs::write(
869                pkg.join("init.lua"),
870                format!("local M = {{}}\nM.meta = {{ name = \"{pkg_name}\" }}\nreturn M\n"),
871            )
872            .expect("write init.lua");
873        }
874
875        /// Write an init.lua with an explicit `M.meta.type = "library"` — this
876        /// gives `type_source = "explicit"` via `LUA_TYPE_AUTODETECT`, so the
877        /// entry must go to `Unregistered`.
878        fn write_explicit_type_init_lua(pkg_dir: &std::path::Path, pkg_name: &str) {
879            let pkg = pkg_dir.join(pkg_name);
880            std::fs::create_dir_all(&pkg).expect("create pkg dir");
881            std::fs::write(
882                pkg.join("init.lua"),
883                format!(
884                    "local M = {{}}\nM.meta = {{ name = \"{pkg_name}\", type = \"library\" }}\nreturn M\n"
885                ),
886            )
887            .expect("write init.lua");
888        }
889
890        /// (a) A dangling symlink (target directory absent) must be excluded.
891        #[tokio::test]
892        async fn dangling_symlink_excluded() {
893            let tmp = tempfile::tempdir().expect("create tempdir");
894            let pkg_dir = tmp.path().join("packages");
895            std::fs::create_dir_all(&pkg_dir).expect("create packages dir");
896
897            // Point the symlink at a non-existent directory.
898            let link = pkg_dir.join("ghost_pkg");
899            unix_symlink(tmp.path().join("does_not_exist"), &link)
900                .expect("create dangling symlink");
901
902            let svc = make_app_service_at(tmp.path().to_path_buf()).await;
903            let registered = HashSet::new();
904            let registered_paths: Vec<PathBuf> = vec![];
905            let result = svc
906                .collect_alive_unregistered_symlinks(&pkg_dir, &registered, &registered_paths, None)
907                .await
908                .expect("helper should not error");
909
910            assert!(
911                result.is_empty(),
912                "dangling symlink must not appear in result"
913            );
914        }
915
916        /// (b) An alive symlink + unregistered + init.lua with no explicit type and no
917        /// run function → `LUA_TYPE_AUTODETECT` sets type_source = "auto_detected_library"
918        /// → `AliveBucket::UnmarkedLibrary`.
919        #[tokio::test]
920        async fn alive_unregistered_auto_library_routes_to_unmarked_library() {
921            let tmp = tempfile::tempdir().expect("create tempdir");
922            let real_pkgs = tmp.path().join("real");
923            let pkg_dir = tmp.path().join("packages");
924            std::fs::create_dir_all(&pkg_dir).expect("create packages dir");
925
926            // Real pkg directory (link target).
927            write_auto_library_init_lua(&real_pkgs, "my_lib");
928
929            // Alive symlink in packages/ pointing at real/my_lib.
930            unix_symlink(real_pkgs.join("my_lib"), pkg_dir.join("my_lib"))
931                .expect("create alive symlink");
932
933            let svc = make_app_service_at(tmp.path().to_path_buf()).await;
934            let registered = HashSet::new();
935            let registered_paths: Vec<PathBuf> = vec![];
936            let result = svc
937                .collect_alive_unregistered_symlinks(&pkg_dir, &registered, &registered_paths, None)
938                .await
939                .expect("helper should not error");
940
941            assert_eq!(result.len(), 1);
942            assert_eq!(result[0].0, "my_lib");
943            assert_eq!(result[0].1, AliveBucket::UnmarkedLibrary);
944        }
945
946        /// (c) An alive symlink + unregistered + init.lua with explicit
947        /// `M.meta.type = "library"` → `type_source = "explicit"` via
948        /// `LUA_TYPE_AUTODETECT` → `AliveBucket::Unregistered`.
949        #[tokio::test]
950        async fn alive_unregistered_explicit_type_routes_to_unregistered() {
951            let tmp = tempfile::tempdir().expect("create tempdir");
952            let real_pkgs = tmp.path().join("real");
953            let pkg_dir = tmp.path().join("packages");
954            std::fs::create_dir_all(&pkg_dir).expect("create packages dir");
955
956            write_explicit_type_init_lua(&real_pkgs, "explicit_lib");
957
958            unix_symlink(real_pkgs.join("explicit_lib"), pkg_dir.join("explicit_lib"))
959                .expect("create alive symlink");
960
961            let svc = make_app_service_at(tmp.path().to_path_buf()).await;
962            let registered = HashSet::new();
963            let registered_paths: Vec<PathBuf> = vec![];
964            let result = svc
965                .collect_alive_unregistered_symlinks(&pkg_dir, &registered, &registered_paths, None)
966                .await
967                .expect("helper should not error");
968
969            assert_eq!(result.len(), 1);
970            assert_eq!(result[0].0, "explicit_lib");
971            assert_eq!(result[0].1, AliveBucket::Unregistered);
972        }
973
974        /// (d) An alive symlink whose name appears in `registered` must be skipped.
975        #[tokio::test]
976        async fn alive_registered_pkg_excluded() {
977            let tmp = tempfile::tempdir().expect("create tempdir");
978            let real_pkgs = tmp.path().join("real");
979            let pkg_dir = tmp.path().join("packages");
980            std::fs::create_dir_all(&pkg_dir).expect("create packages dir");
981
982            write_auto_library_init_lua(&real_pkgs, "known_pkg");
983
984            unix_symlink(real_pkgs.join("known_pkg"), pkg_dir.join("known_pkg"))
985                .expect("create alive symlink");
986
987            let svc = make_app_service_at(tmp.path().to_path_buf()).await;
988            let mut registered = HashSet::new();
989            registered.insert("known_pkg".to_string());
990            let registered_paths: Vec<PathBuf> = vec![];
991            let result = svc
992                .collect_alive_unregistered_symlinks(&pkg_dir, &registered, &registered_paths, None)
993                .await
994                .expect("helper should not error");
995
996            assert!(
997                result.is_empty(),
998                "registered pkg must not appear in result"
999            );
1000        }
1001
1002        /// (e) target_filter restricts output to the named package only.
1003        #[tokio::test]
1004        async fn target_filter_restricts_output() {
1005            let tmp = tempfile::tempdir().expect("create tempdir");
1006            let real_pkgs = tmp.path().join("real");
1007            let pkg_dir = tmp.path().join("packages");
1008            std::fs::create_dir_all(&pkg_dir).expect("create packages dir");
1009
1010            write_auto_library_init_lua(&real_pkgs, "lib_a");
1011            write_auto_library_init_lua(&real_pkgs, "lib_b");
1012
1013            unix_symlink(real_pkgs.join("lib_a"), pkg_dir.join("lib_a"))
1014                .expect("create symlink lib_a");
1015            unix_symlink(real_pkgs.join("lib_b"), pkg_dir.join("lib_b"))
1016                .expect("create symlink lib_b");
1017
1018            let svc = make_app_service_at(tmp.path().to_path_buf()).await;
1019            let registered = HashSet::new();
1020            let registered_paths: Vec<PathBuf> = vec![];
1021            let result = svc
1022                .collect_alive_unregistered_symlinks(
1023                    &pkg_dir,
1024                    &registered,
1025                    &registered_paths,
1026                    Some("lib_a"),
1027                )
1028                .await
1029                .expect("helper should not error");
1030
1031            assert_eq!(result.len(), 1);
1032            assert_eq!(result[0].0, "lib_a");
1033        }
1034
1035        /// (f) An entry whose canonicalized path appears in `registered_paths`
1036        /// must be skipped (path-dep false-positive guard).
1037        #[tokio::test]
1038        async fn registered_path_dep_excluded() {
1039            let tmp = tempfile::tempdir().expect("create tempdir");
1040            let real_pkgs = tmp.path().join("real");
1041            let pkg_dir = tmp.path().join("packages");
1042            std::fs::create_dir_all(&pkg_dir).expect("create packages dir");
1043
1044            write_auto_library_init_lua(&real_pkgs, "path_dep_lib");
1045
1046            let real_dir = real_pkgs.join("path_dep_lib");
1047            unix_symlink(&real_dir, pkg_dir.join("path_dep_lib")).expect("create alive symlink");
1048
1049            // Canonicalize the real dir to simulate what registered_paths contains.
1050            let canonical = real_dir.canonicalize().expect("canonicalize real dir");
1051
1052            let svc = make_app_service_at(tmp.path().to_path_buf()).await;
1053            let registered = HashSet::new();
1054            let registered_paths = vec![canonical];
1055            let result = svc
1056                .collect_alive_unregistered_symlinks(&pkg_dir, &registered, &registered_paths, None)
1057                .await
1058                .expect("helper should not error");
1059
1060            assert!(
1061                result.is_empty(),
1062                "path-dep registered entry must not appear in result"
1063            );
1064        }
1065    }
1066}