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::path::{Path, PathBuf};
16
17use super::super::alc_toml::{self, PackageDep};
18use super::super::lockfile::load_lockfile;
19use super::super::manifest::{load_manifest, ManifestEntry};
20use super::super::resolve::packages_dir;
21use super::super::source::PackageSource;
22use super::super::AppService;
23use super::install::InstallSource;
24
25/// Outcome of repairing a single manifest-tracked package.
26enum RepairOutcome {
27    /// Successfully reinstalled from `source`.
28    Repaired { source: String },
29    /// Package is healthy — nothing to do.
30    Skipped,
31    /// Cannot repair automatically — user must intervene. `kind` is emitted
32    /// verbatim into the JSON bucket entry, letting a single variant carry
33    /// both the `installed_missing` sub-kinds (bundled / path) and the
34    /// `symlink_dangling` case (dangling symlink at a manifest-tracked name).
35    Unrepairable {
36        kind: &'static str,
37        reason: String,
38        suggestion: String,
39    },
40    /// Repair was attempted but failed.
41    Failed { reason: String },
42}
43
44/// Accumulator for the four JSON output buckets.
45#[derive(Default)]
46struct Buckets {
47    repaired: Vec<serde_json::Value>,
48    skipped: Vec<serde_json::Value>,
49    unrepairable: Vec<serde_json::Value>,
50    failed: Vec<serde_json::Value>,
51}
52
53impl Buckets {
54    fn any_matched(&self) -> bool {
55        !self.repaired.is_empty()
56            || !self.skipped.is_empty()
57            || !self.unrepairable.is_empty()
58            || !self.failed.is_empty()
59    }
60
61    fn into_json(self) -> String {
62        serde_json::json!({
63            "repaired": self.repaired,
64            "skipped": self.skipped,
65            "unrepairable": self.unrepairable,
66            "failed": self.failed,
67        })
68        .to_string()
69    }
70}
71
72/// Suggestion string shared by the manifest-pass dangling-symlink case and
73/// the (A) unattached-symlink pass.
74pub(super) fn symlink_dangling_suggestion(name: &str) -> String {
75    format!("alc_pkg_unlink({name:?}) then alc_pkg_link with the new path")
76}
77
78/// Push a manifest-pass outcome into the appropriate bucket. Non-Unrepairable
79/// outcomes use `kind = "installed_missing"`; Unrepairable carries its own
80/// kind so both `installed_missing` (bundled/path) and `symlink_dangling`
81/// can flow through the same helper.
82fn push_installed_outcome(name: &str, outcome: RepairOutcome, buckets: &mut Buckets) {
83    match outcome {
84        RepairOutcome::Repaired { source } => buckets.repaired.push(serde_json::json!({
85            "name": name,
86            "kind": "installed_missing",
87            "action": "reinstall",
88            "source": source,
89        })),
90        RepairOutcome::Skipped => buckets.skipped.push(serde_json::json!({
91            "name": name,
92            "reason": "healthy",
93        })),
94        RepairOutcome::Unrepairable {
95            kind,
96            reason,
97            suggestion,
98        } => buckets.unrepairable.push(serde_json::json!({
99            "name": name,
100            "kind": kind,
101            "reason": reason,
102            "suggestion": suggestion,
103        })),
104        RepairOutcome::Failed { reason } => buckets.failed.push(serde_json::json!({
105            "name": name,
106            "kind": "installed_missing",
107            "reason": reason,
108        })),
109    }
110}
111
112impl AppService {
113    /// Heal broken packages by re-installing from `installed.json` source.
114    ///
115    /// `name` — restrict to a single package; `None` repairs every broken pkg.
116    /// `project_root` — used for project / variant pkg path checks. Falls back
117    /// to ancestor walk from cwd.
118    ///
119    /// Returns JSON with `repaired`, `skipped`, `unrepairable`, `failed`
120    /// arrays (each entry has `name` + per-bucket fields). Repair is
121    /// best-effort: the per-pkg result is reported regardless of outcome.
122    pub async fn pkg_repair(
123        &self,
124        name: Option<String>,
125        project_root: Option<String>,
126    ) -> Result<String, String> {
127        let app_dir = self.log_config.app_dir();
128        let manifest = load_manifest(&app_dir)?;
129        let pkg_dir = packages_dir(&app_dir);
130        let resolved_root = self.resolve_root(project_root.as_deref());
131
132        let mut buckets = Buckets::default();
133        let target_filter = name.as_deref();
134
135        // ── (B) installed pkgs from manifest ──────────────────────
136        for (pkg_name, entry) in &manifest.packages {
137            if let Some(target) = target_filter {
138                if target != pkg_name.as_str() {
139                    continue;
140                }
141            }
142            let outcome = self.repair_installed(pkg_name, entry, &pkg_dir).await;
143            push_installed_outcome(pkg_name, outcome, &mut buckets);
144        }
145
146        // ── (A) unattached dangling symlinks (no manifest entry) ──
147        collect_unattached_dangling_symlinks(
148            &pkg_dir,
149            target_filter,
150            &manifest.packages,
151            &mut buckets.unrepairable,
152        );
153
154        // ── (C) project `path = ...` missing ──────────────────────
155        // ── (D) variant `path = ...` missing ──────────────────────
156        if let Some(root) = resolved_root.as_ref() {
157            collect_path_missing(
158                root,
159                target_filter,
160                "project",
161                &mut buckets.unrepairable,
162                ProjectPathSource::Toml,
163            );
164            collect_path_missing(
165                root,
166                target_filter,
167                "variant",
168                &mut buckets.unrepairable,
169                ProjectPathSource::Local,
170            );
171        }
172
173        if let Some(target) = target_filter {
174            if !buckets.any_matched() {
175                return Err(format!(
176                    "Package '{target}' not found in installed.json, ~/.algocline/packages/, alc.toml, or alc.local.toml"
177                ));
178            }
179        }
180
181        Ok(buckets.into_json())
182    }
183
184    /// Attempt to repair a single manifest-tracked package by re-running
185    /// `pkg_install` with the recorded `source`. Returns `Skipped` when the
186    /// package directory already exists (healthy), or Unrepairable with
187    /// `kind = "symlink_dangling"` when dest is a dangling symlink — the
188    /// (A) pass's "skip if in manifest" rule would otherwise drop this case.
189    async fn repair_installed(
190        &self,
191        name: &str,
192        entry: &ManifestEntry,
193        pkg_dir: &Path,
194    ) -> RepairOutcome {
195        let dest = pkg_dir.join(name);
196
197        let is_symlink = dest
198            .symlink_metadata()
199            .map(|m| m.file_type().is_symlink())
200            .unwrap_or(false);
201        if is_symlink {
202            // `try_exists` follows the symlink — true iff target is alive.
203            let target_alive = dest.try_exists().unwrap_or(false);
204            if target_alive {
205                return RepairOutcome::Skipped;
206            }
207            let link_target = dest
208                .read_link()
209                .map(|t| t.display().to_string())
210                .unwrap_or_else(|_| "<unknown>".to_string());
211            return RepairOutcome::Unrepairable {
212                kind: "symlink_dangling",
213                reason: format!("symlink target missing: {link_target}"),
214                suggestion: symlink_dangling_suggestion(name),
215            };
216        }
217
218        if dest.exists() {
219            return RepairOutcome::Skipped;
220        }
221
222        // Source classification: only `Path` (local copy) and `Git` can be
223        // re-fetched. Bundled is conceptually re-installable via `alc_init`;
224        // `Installed` is a legacy marker that carries no re-fetch info (the
225        // typed successor is `Path { path }`). `Unknown` is the pre-typed
226        // "source unrecorded" landing site and is structurally unrepairable.
227        //
228        // States detectable before attempting install belong in `unrepairable`,
229        // not `failed`. `failed` is reserved for runtime errors during an
230        // actual install attempt.
231        let install_source = match &entry.source {
232            PackageSource::Path { path } => InstallSource::LocalPath(PathBuf::from(path)),
233            PackageSource::Git { url, .. } => InstallSource::GitUrl(normalize_git_url(url)),
234            PackageSource::Bundled { .. } => {
235                return RepairOutcome::Unrepairable {
236                    kind: "installed_missing",
237                    reason: "bundled package — restore via `alc_init` or reinstall algocline"
238                        .to_string(),
239                    suggestion: "alc_init (reinstalls bundled packages from the algocline binary)"
240                        .to_string(),
241                };
242            }
243            PackageSource::Installed => {
244                // Legacy marker: pre-typed manifest that recorded a local install
245                // as `source: "installed"` / absolute path (see
246                // `infer_from_legacy_source_string`). The actual source path is
247                // lost, so we cannot re-fetch automatically.
248                return RepairOutcome::Unrepairable {
249                    kind: "installed_missing",
250                    reason: "legacy 'installed' marker carries no source path".to_string(),
251                    suggestion: "alc_pkg_install <path-or-url> to re-record source, \
252                                 then alc_pkg_repair"
253                        .to_string(),
254                };
255            }
256            PackageSource::Unknown => {
257                // Pre-typed manifest entry with `source: ""` (never recorded).
258                // Routed here per the Phase 3 spec: `Unknown` must land in
259                // `Unrepairable`, not be silently coerced.
260                return RepairOutcome::Unrepairable {
261                    kind: "installed_missing",
262                    reason: "source unknown (legacy entry; run alc_hub_reindex)".to_string(),
263                    suggestion: "alc_hub_reindex to rebuild the index, or \
264                                 alc_pkg_install <path-or-url> to re-record source"
265                        .to_string(),
266                };
267            }
268        };
269
270        // Pre-check: a LocalPath is structurally unrepairable when
271        // (a) the source directory no longer exists, or
272        // (b) the source exists but has no `init.lua` at its root.
273        // (b) matters because `install_from_local_path` routes a no-root-init
274        // source into collection mode, which rejects the `name` argument that
275        // repair must pass — the combination is unreachable with the current
276        // install layer, so there are no bytes to copy for *this* named pkg.
277        // Classify both up front rather than letting the install layer fail
278        // at runtime; that would land in `failed`, mixing structural
279        // impossibility with transient runtime failures.
280        //
281        // Git sources are deliberately not pre-checked here: network/remote
282        // availability is a runtime concern that belongs in the attempt path.
283        if let InstallSource::LocalPath(ref p) = install_source {
284            if !p.exists() {
285                return RepairOutcome::Unrepairable {
286                    kind: "installed_missing",
287                    reason: format!("source directory missing: {}", p.display()),
288                    suggestion: format!(
289                        "alc_pkg_install from a valid source, or remove the '{name}' entry from ~/.algocline/installed.json"
290                    ),
291                };
292            }
293            if !p.join("init.lua").exists() {
294                return RepairOutcome::Unrepairable {
295                    kind: "installed_missing",
296                    reason: format!(
297                        "source directory has no init.lua at root: {}",
298                        p.display()
299                    ),
300                    suggestion: format!(
301                        "alc_pkg_install from a valid source, or remove the '{name}' entry from ~/.algocline/installed.json"
302                    ),
303                };
304            }
305        }
306
307        match self
308            .pkg_install_typed(install_source, Some(name.to_string()), None)
309            .await
310        {
311            Ok(_) => RepairOutcome::Repaired {
312                // Emit a human-readable source string (legacy schema). The
313                // typed source is already persisted back into the manifest
314                // by the install path — this field is just display.
315                source: entry.source.display_string(),
316            },
317            Err(e) => RepairOutcome::Failed { reason: e },
318        }
319    }
320}
321
322/// Apply the same URL scheme normalization `classify_install_url` uses
323/// without re-checking whether the string refers to a local directory.
324/// Repair has already established the source is Git (typed
325/// `PackageSource::Git`); re-classifying via the directory heuristic would
326/// be both redundant and racy. Delegates to the shared
327/// [`super::install::prefix_git_scheme_if_missing`] helper so that install
328/// and repair stay in lockstep on scheme handling.
329fn normalize_git_url(url: &str) -> String {
330    super::install::prefix_git_scheme_if_missing(url)
331}
332
333/// Scan `pkg_dir` for dangling symlinks whose name is *not* present in the
334/// manifest. Manifest-tracked names are handled by `repair_installed` so
335/// they're skipped here to avoid double-counting.
336pub(super) fn collect_unattached_dangling_symlinks(
337    pkg_dir: &Path,
338    target_filter: Option<&str>,
339    manifest_names: &std::collections::BTreeMap<String, ManifestEntry>,
340    unrepairable: &mut Vec<serde_json::Value>,
341) {
342    let read = match std::fs::read_dir(pkg_dir) {
343        Ok(r) => r,
344        Err(e) => {
345            tracing::warn!(
346                "pkg: failed to read packages_dir at {}: {e}",
347                pkg_dir.display()
348            );
349            return;
350        }
351    };
352
353    for dir_entry_result in read {
354        let dir_entry = match dir_entry_result {
355            Ok(e) => e,
356            Err(e) => {
357                // Previously this scan used `read.flatten()` which dropped
358                // per-entry I/O errors silently. Some names (permission
359                // denials, transient FS errors) therefore slipped through
360                // the dangling-symlink check without diagnosis. Log here
361                // so at least the repair attempt leaves a trail.
362                tracing::warn!(
363                    "pkg: skipping unreadable entry in {}: {e}",
364                    pkg_dir.display()
365                );
366                continue;
367            }
368        };
369        let path = dir_entry.path();
370        let pkg_name = dir_entry.file_name().to_string_lossy().to_string();
371
372        if let Some(target) = target_filter {
373            if target != pkg_name.as_str() {
374                continue;
375            }
376        }
377        if manifest_names.contains_key(&pkg_name) {
378            continue;
379        }
380
381        let is_symlink = path
382            .symlink_metadata()
383            .map(|m| m.file_type().is_symlink())
384            .unwrap_or(false);
385        if !is_symlink {
386            continue;
387        }
388        let target_exists = path.try_exists().unwrap_or(false);
389        if target_exists {
390            continue;
391        }
392
393        let link_target = path
394            .read_link()
395            .map(|t| t.display().to_string())
396            .unwrap_or_else(|_| "<unknown>".to_string());
397
398        unrepairable.push(serde_json::json!({
399            "name": pkg_name,
400            "kind": "symlink_dangling",
401            "reason": format!("symlink target missing: {link_target}"),
402            "suggestion": symlink_dangling_suggestion(&pkg_name),
403        }));
404    }
405}
406
407/// Which TOML file is the source of truth for path entries.
408#[derive(Debug, Clone, Copy)]
409pub(super) enum ProjectPathSource {
410    /// `alc.toml` `[packages.x] path = ...` (project scope).
411    Toml,
412    /// `alc.local.toml` `[packages.x] path = ...` (variant scope).
413    Local,
414}
415
416/// Append `path_missing` unrepairable entries for either alc.toml or
417/// alc.local.toml. Filtering by `target_filter` (Some(name)) restricts
418/// to a single package.
419pub(super) fn collect_path_missing(
420    root: &Path,
421    target_filter: Option<&str>,
422    scope: &'static str,
423    unrepairable: &mut Vec<serde_json::Value>,
424    src: ProjectPathSource,
425) {
426    let loaded = match src {
427        ProjectPathSource::Toml => alc_toml::load_alc_toml(root),
428        ProjectPathSource::Local => alc_toml::load_alc_local_toml(root),
429    };
430    let Ok(Some(toml_data)) = loaded else {
431        return;
432    };
433
434    // For project scope, the lockfile is the more accurate source for the
435    // resolved path (it absorbs canonicalization done at install time). Fall
436    // back to the alc.toml declaration when no lockfile exists.
437    //
438    // TODO(variant-canonicalization): variant scope reads the raw
439    // alc.local.toml path verbatim. If `pkg_link --scope=variant` ever starts
440    // writing relative paths (today it writes absolute), this block will
441    // diverge from what `pkg_list` / `pkg_run` resolve — mirror the project
442    // lockfile lookup for variants at that point.
443    let lock_lookup = if matches!(src, ProjectPathSource::Toml) {
444        load_lockfile(root).ok().flatten().map(|l| {
445            l.packages
446                .into_iter()
447                .filter_map(|p| match p.source {
448                    PackageSource::Path { path } => Some((p.name, path)),
449                    _ => None,
450                })
451                .collect::<std::collections::HashMap<String, String>>()
452        })
453    } else {
454        None
455    };
456
457    for (name, dep) in &toml_data.packages {
458        if let Some(t) = target_filter {
459            if t != name.as_str() {
460                continue;
461            }
462        }
463
464        let raw = match dep {
465            PackageDep::Path { path, .. } => path,
466            _ => continue,
467        };
468
469        let resolved_raw = lock_lookup
470            .as_ref()
471            .and_then(|m| m.get(name).cloned())
472            .unwrap_or_else(|| raw.clone());
473
474        let p = Path::new(&resolved_raw);
475        let abs = if p.is_absolute() {
476            p.to_path_buf()
477        } else {
478            root.join(p)
479        };
480
481        if abs.exists() {
482            continue;
483        }
484
485        let suggestion = match src {
486            ProjectPathSource::Toml => {
487                format!("update or remove [packages.{name}] in alc.toml")
488            }
489            ProjectPathSource::Local => {
490                format!("alc_pkg_unlink({name:?}) or update [packages.{name}] in alc.local.toml")
491            }
492        };
493
494        unrepairable.push(serde_json::json!({
495            "name": name,
496            "kind": "path_missing",
497            "scope": scope,
498            "reason": format!("declared path does not exist: {}", abs.display()),
499            "suggestion": suggestion,
500        }));
501    }
502}