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 the named package's subdirectory
273 // (`<source>/<name>/init.lua`) is absent.
274 //
275 // Since Single-package mode was removed in v0.36.0, all local installs
276 // use collection layout: the recorded source is the collection root and
277 // each package lives at `<source>/<name>/init.lua`. We check for the
278 // named package's own init.lua rather than a root-level one.
279 //
280 // Git sources are deliberately not pre-checked here: network/remote
281 // availability is a runtime concern that belongs in the attempt path.
282 if let InstallSource::LocalPath(ref p) = install_source {
283 if !p.exists() {
284 return RepairOutcome::Unrepairable {
285 kind: "installed_missing",
286 reason: format!("source directory missing: {}", p.display()),
287 suggestion: format!(
288 "alc_pkg_install from a valid source, or remove the '{name}' entry from ~/.algocline/installed.json"
289 ),
290 };
291 }
292 if !p.join(name).join("init.lua").exists() {
293 return RepairOutcome::Unrepairable {
294 kind: "installed_missing",
295 reason: format!(
296 "source directory has no init.lua at root: {}",
297 p.display()
298 ),
299 suggestion: format!(
300 "alc_pkg_install from a valid source, or remove the '{name}' entry from ~/.algocline/installed.json"
301 ),
302 };
303 }
304 }
305
306 // Re-install from the collection root. Single-package mode was removed
307 // in v0.36.0, so `name` is not passed; `install_from_local_path` will
308 // scan `<source>/<name>/init.lua` and reinstall all packages found in
309 // the collection. For the common case of a 1-entry collection this is
310 // equivalent to targeted reinstall.
311 match self.pkg_install_typed(install_source, None, None).await {
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}