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