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