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.
75fn 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 manifest = load_manifest()?;
129 let pkg_dir = packages_dir()?;
130 let resolved_root = resolve_project_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 `Installed` (local copy) and `Git` can be
223 // re-fetched. Bundled is conceptually re-installable via `alc_init`;
224 // Path sources are not tracked in the manifest for repair.
225 //
226 // `infer_from_legacy_source_string` classifies **syntactically** (shape,
227 // not filesystem existence), so a manifest entry whose local source
228 // directory has since been deleted still maps to `Installed`. That case
229 // is a structural dead-end (nothing to copy from), so we route it to
230 // `Unrepairable` in the pre-check below — matching the policy used for
231 // Bundled / Path sources: states detectable before attempting install
232 // belong in `unrepairable`, not `failed`. `failed` is reserved for
233 // runtime errors during an actual install attempt.
234 let inferred = super::super::source::infer_from_legacy_source_string(&entry.source);
235 let install_source = match inferred {
236 PackageSource::Installed => InstallSource::LocalPath(PathBuf::from(&entry.source)),
237 PackageSource::Git { url, .. } => InstallSource::GitUrl(normalize_git_url(&url)),
238 PackageSource::Bundled { .. } => {
239 return RepairOutcome::Unrepairable {
240 kind: "installed_missing",
241 reason: "bundled package — restore via `alc_init` or reinstall algocline"
242 .to_string(),
243 suggestion: "alc_init (reinstalls bundled packages from the algocline binary)"
244 .to_string(),
245 };
246 }
247 // Defensive: `infer_from_legacy_source_string` never constructs
248 // `PackageSource::Path` today (it only emits Bundled / Installed /
249 // Git). We keep this arm as an exhaustive-match guard so future
250 // additions to the inference function don't silently break the
251 // repair path — the explicit Unrepairable is a safer default than
252 // `_ => unreachable!()`, which would panic if the guarantee ever
253 // erodes.
254 PackageSource::Path { path } => {
255 return RepairOutcome::Unrepairable {
256 kind: "installed_missing",
257 reason: format!("path source ({path}) — not tracked in manifest for repair"),
258 suggestion: "edit alc.toml or alc.local.toml directly".to_string(),
259 };
260 }
261 };
262
263 // Pre-check: a LocalPath is structurally unrepairable when
264 // (a) the source directory no longer exists, or
265 // (b) the source exists but has no `init.lua` at its root.
266 // (b) matters because `install_from_local_path` routes a no-root-init
267 // source into collection mode, which rejects the `name` argument that
268 // repair must pass — the combination is unreachable with the current
269 // install layer, so there are no bytes to copy for *this* named pkg.
270 // Classify both up front rather than letting the install layer fail
271 // at runtime; that would land in `failed`, mixing structural
272 // impossibility with transient runtime failures.
273 //
274 // Git sources are deliberately not pre-checked here: network/remote
275 // availability is a runtime concern that belongs in the attempt path.
276 if let InstallSource::LocalPath(ref p) = install_source {
277 if !p.exists() {
278 return RepairOutcome::Unrepairable {
279 kind: "installed_missing",
280 reason: format!("source directory missing: {}", p.display()),
281 suggestion: format!(
282 "alc_pkg_install from a valid source, or remove the '{name}' entry from ~/.algocline/installed.json"
283 ),
284 };
285 }
286 if !p.join("init.lua").exists() {
287 return RepairOutcome::Unrepairable {
288 kind: "installed_missing",
289 reason: format!(
290 "source directory has no init.lua at root: {}",
291 p.display()
292 ),
293 suggestion: format!(
294 "alc_pkg_install from a valid source, or remove the '{name}' entry from ~/.algocline/installed.json"
295 ),
296 };
297 }
298 }
299
300 match self
301 .pkg_install_typed(install_source, Some(name.to_string()))
302 .await
303 {
304 Ok(_) => RepairOutcome::Repaired {
305 source: entry.source.clone(),
306 },
307 Err(e) => RepairOutcome::Failed { reason: e },
308 }
309 }
310}
311
312/// Apply the same URL normalization `classify_install_url` uses (prefix
313/// `https://` to bare domain-style URLs) without re-checking whether the
314/// string refers to a local directory. Repair has already established the
315/// source is Git; re-classifying via the directory heuristic would be both
316/// redundant and racy.
317fn normalize_git_url(url: &str) -> String {
318 if url.starts_with("http://")
319 || url.starts_with("https://")
320 || url.starts_with("file://")
321 || url.starts_with("git@")
322 {
323 url.to_string()
324 } else {
325 format!("https://{url}")
326 }
327}
328
329/// Scan `pkg_dir` for dangling symlinks whose name is *not* present in the
330/// manifest. Manifest-tracked names are handled by `repair_installed` so
331/// they're skipped here to avoid double-counting.
332fn collect_unattached_dangling_symlinks(
333 pkg_dir: &Path,
334 target_filter: Option<&str>,
335 manifest_names: &std::collections::BTreeMap<String, ManifestEntry>,
336 unrepairable: &mut Vec<serde_json::Value>,
337) {
338 let read = match std::fs::read_dir(pkg_dir) {
339 Ok(r) => r,
340 Err(e) => {
341 tracing::warn!(
342 "pkg_repair: failed to read packages_dir at {}: {e}",
343 pkg_dir.display()
344 );
345 return;
346 }
347 };
348
349 for dir_entry_result in read {
350 let dir_entry = match dir_entry_result {
351 Ok(e) => e,
352 Err(e) => {
353 // Previously this scan used `read.flatten()` which dropped
354 // per-entry I/O errors silently. Some names (permission
355 // denials, transient FS errors) therefore slipped through
356 // the dangling-symlink check without diagnosis. Log here
357 // so at least the repair attempt leaves a trail.
358 tracing::warn!(
359 "pkg_repair: skipping unreadable entry in {}: {e}",
360 pkg_dir.display()
361 );
362 continue;
363 }
364 };
365 let path = dir_entry.path();
366 let pkg_name = dir_entry.file_name().to_string_lossy().to_string();
367
368 if let Some(target) = target_filter {
369 if target != pkg_name.as_str() {
370 continue;
371 }
372 }
373 if manifest_names.contains_key(&pkg_name) {
374 continue;
375 }
376
377 let is_symlink = path
378 .symlink_metadata()
379 .map(|m| m.file_type().is_symlink())
380 .unwrap_or(false);
381 if !is_symlink {
382 continue;
383 }
384 let target_exists = path.try_exists().unwrap_or(false);
385 if target_exists {
386 continue;
387 }
388
389 let link_target = path
390 .read_link()
391 .map(|t| t.display().to_string())
392 .unwrap_or_else(|_| "<unknown>".to_string());
393
394 unrepairable.push(serde_json::json!({
395 "name": pkg_name,
396 "kind": "symlink_dangling",
397 "reason": format!("symlink target missing: {link_target}"),
398 "suggestion": symlink_dangling_suggestion(&pkg_name),
399 }));
400 }
401}
402
403/// Which TOML file is the source of truth for path entries.
404#[derive(Debug, Clone, Copy)]
405enum ProjectPathSource {
406 /// `alc.toml` `[packages.x] path = ...` (project scope).
407 Toml,
408 /// `alc.local.toml` `[packages.x] path = ...` (variant scope).
409 Local,
410}
411
412/// Append `path_missing` unrepairable entries for either alc.toml or
413/// alc.local.toml. Filtering by `target_filter` (Some(name)) restricts
414/// to a single package.
415fn collect_path_missing(
416 root: &Path,
417 target_filter: Option<&str>,
418 scope: &'static str,
419 unrepairable: &mut Vec<serde_json::Value>,
420 src: ProjectPathSource,
421) {
422 let loaded = match src {
423 ProjectPathSource::Toml => alc_toml::load_alc_toml(root),
424 ProjectPathSource::Local => alc_toml::load_alc_local_toml(root),
425 };
426 let Ok(Some(toml_data)) = loaded else {
427 return;
428 };
429
430 // For project scope, the lockfile is the more accurate source for the
431 // resolved path (it absorbs canonicalization done at install time). Fall
432 // back to the alc.toml declaration when no lockfile exists.
433 //
434 // TODO(variant-canonicalization): variant scope reads the raw
435 // alc.local.toml path verbatim. If `pkg_link --scope=variant` ever starts
436 // writing relative paths (today it writes absolute), this block will
437 // diverge from what `pkg_list` / `pkg_run` resolve — mirror the project
438 // lockfile lookup for variants at that point.
439 let lock_lookup = if matches!(src, ProjectPathSource::Toml) {
440 load_lockfile(root).ok().flatten().map(|l| {
441 l.packages
442 .into_iter()
443 .filter_map(|p| match p.source {
444 PackageSource::Path { path } => Some((p.name, path)),
445 _ => None,
446 })
447 .collect::<std::collections::HashMap<String, String>>()
448 })
449 } else {
450 None
451 };
452
453 for (name, dep) in &toml_data.packages {
454 if let Some(t) = target_filter {
455 if t != name.as_str() {
456 continue;
457 }
458 }
459
460 let raw = match dep {
461 PackageDep::Path { path, .. } => path,
462 _ => continue,
463 };
464
465 let resolved_raw = lock_lookup
466 .as_ref()
467 .and_then(|m| m.get(name).cloned())
468 .unwrap_or_else(|| raw.clone());
469
470 let p = Path::new(&resolved_raw);
471 let abs = if p.is_absolute() {
472 p.to_path_buf()
473 } else {
474 root.join(p)
475 };
476
477 if abs.exists() {
478 continue;
479 }
480
481 let suggestion = match src {
482 ProjectPathSource::Toml => {
483 format!("update or remove [packages.{name}] in alc.toml")
484 }
485 ProjectPathSource::Local => {
486 format!("alc_pkg_unlink({name:?}) or update [packages.{name}] in alc.local.toml")
487 }
488 };
489
490 unrepairable.push(serde_json::json!({
491 "name": name,
492 "kind": "path_missing",
493 "scope": scope,
494 "reason": format!("declared path does not exist: {}", abs.display()),
495 "suggestion": suggestion,
496 }));
497 }
498}