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/// 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 = self.resolve_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 the named package's subdirectory
274 // (`<source>/<name>/init.lua`) is absent.
275 //
276 // Since Single-package mode was removed in v0.36.0, all local installs
277 // use collection layout: the recorded source is the collection root and
278 // each package lives at `<source>/<name>/init.lua`. We check for the
279 // named package's own init.lua rather than a root-level one.
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(name).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 // Re-install from the collection root. Single-package mode was removed
308 // in v0.36.0, so `name` is not passed; `install_from_local_path` will
309 // scan `<source>/<name>/init.lua` and reinstall all packages found in
310 // the collection. For the common case of a 1-entry collection this is
311 // equivalent to targeted reinstall.
312 match self.pkg_install_typed(install_source, None, None).await {
313 Ok(_) => RepairOutcome::Repaired {
314 // Emit a human-readable source string (legacy schema). The
315 // typed source is already persisted back into the manifest
316 // by the install path — this field is just display.
317 source: entry.source.display_string(),
318 },
319 Err(e) => RepairOutcome::Failed { reason: e },
320 }
321 }
322}
323
324/// Apply the same URL scheme normalization `classify_install_url` uses
325/// without re-checking whether the string refers to a local directory.
326/// Repair has already established the source is Git (typed
327/// `PackageSource::Git`); re-classifying via the directory heuristic would
328/// be both redundant and racy. Delegates to the shared
329/// [`super::install::prefix_git_scheme_if_missing`] helper so that install
330/// and repair stay in lockstep on scheme handling.
331fn normalize_git_url(url: &str) -> String {
332 super::install::prefix_git_scheme_if_missing(url)
333}
334
335/// Scan `pkg_dir` for dangling symlinks whose name is *not* present in the
336/// manifest. Manifest-tracked names are handled by `repair_installed` so
337/// they're skipped here to avoid double-counting.
338pub(super) fn collect_unattached_dangling_symlinks(
339 pkg_dir: &Path,
340 target_filter: Option<&str>,
341 manifest_names: &std::collections::BTreeMap<String, ManifestEntry>,
342 unrepairable: &mut Vec<serde_json::Value>,
343) {
344 let read = match std::fs::read_dir(pkg_dir) {
345 Ok(r) => r,
346 Err(e) => {
347 tracing::warn!(
348 "pkg: failed to read packages_dir at {}: {e}",
349 pkg_dir.display()
350 );
351 return;
352 }
353 };
354
355 for dir_entry_result in read {
356 let dir_entry = match dir_entry_result {
357 Ok(e) => e,
358 Err(e) => {
359 // Previously this scan used `read.flatten()` which dropped
360 // per-entry I/O errors silently. Some names (permission
361 // denials, transient FS errors) therefore slipped through
362 // the dangling-symlink check without diagnosis. Log here
363 // so at least the repair attempt leaves a trail.
364 tracing::warn!(
365 "pkg: skipping unreadable entry in {}: {e}",
366 pkg_dir.display()
367 );
368 continue;
369 }
370 };
371 let path = dir_entry.path();
372 let pkg_name = dir_entry.file_name().to_string_lossy().to_string();
373
374 if let Some(target) = target_filter {
375 if target != pkg_name.as_str() {
376 continue;
377 }
378 }
379 if manifest_names.contains_key(&pkg_name) {
380 continue;
381 }
382
383 let is_symlink = path
384 .symlink_metadata()
385 .map(|m| m.file_type().is_symlink())
386 .unwrap_or(false);
387 if !is_symlink {
388 continue;
389 }
390 let target_exists = path.try_exists().unwrap_or(false);
391 if target_exists {
392 continue;
393 }
394
395 let link_target = path
396 .read_link()
397 .map(|t| t.display().to_string())
398 .unwrap_or_else(|_| "<unknown>".to_string());
399
400 unrepairable.push(serde_json::json!({
401 "name": pkg_name,
402 "kind": "symlink_dangling",
403 "reason": format!("symlink target missing: {link_target}"),
404 "suggestion": symlink_dangling_suggestion(&pkg_name),
405 }));
406 }
407}
408
409/// Which TOML file is the source of truth for path entries.
410#[derive(Debug, Clone, Copy)]
411pub(super) enum ProjectPathSource {
412 /// `alc.toml` `[packages.x] path = ...` (project scope).
413 Toml,
414 /// `alc.local.toml` `[packages.x] path = ...` (variant scope).
415 Local,
416}
417
418/// Append `path_missing` unrepairable entries for either alc.toml or
419/// alc.local.toml. Filtering by `target_filter` (Some(name)) restricts
420/// to a single package.
421pub(super) fn collect_path_missing(
422 root: &Path,
423 target_filter: Option<&str>,
424 scope: &'static str,
425 unrepairable: &mut Vec<serde_json::Value>,
426 src: ProjectPathSource,
427) {
428 let loaded = match src {
429 ProjectPathSource::Toml => alc_toml::load_alc_toml(root),
430 ProjectPathSource::Local => alc_toml::load_alc_local_toml(root),
431 };
432 let Ok(Some(toml_data)) = loaded else {
433 return;
434 };
435
436 // For project scope, the lockfile is the more accurate source for the
437 // resolved path (it absorbs canonicalization done at install time). Fall
438 // back to the alc.toml declaration when no lockfile exists.
439 //
440 // TODO(variant-canonicalization): variant scope reads the raw
441 // alc.local.toml path verbatim. If `pkg_link --scope=variant` ever starts
442 // writing relative paths (today it writes absolute), this block will
443 // diverge from what `pkg_list` / `pkg_run` resolve — mirror the project
444 // lockfile lookup for variants at that point.
445 let lock_lookup = if matches!(src, ProjectPathSource::Toml) {
446 load_lockfile(root).ok().flatten().map(|l| {
447 l.packages
448 .into_iter()
449 .filter_map(|p| match p.source {
450 PackageSource::Path { path } => Some((p.name, path)),
451 _ => None,
452 })
453 .collect::<std::collections::HashMap<String, String>>()
454 })
455 } else {
456 None
457 };
458
459 for (name, dep) in &toml_data.packages {
460 if let Some(t) = target_filter {
461 if t != name.as_str() {
462 continue;
463 }
464 }
465
466 let raw = match dep {
467 PackageDep::Path { path, .. } => path,
468 _ => continue,
469 };
470
471 let resolved_raw = lock_lookup
472 .as_ref()
473 .and_then(|m| m.get(name).cloned())
474 .unwrap_or_else(|| raw.clone());
475
476 let p = Path::new(&resolved_raw);
477 let abs = if p.is_absolute() {
478 p.to_path_buf()
479 } else {
480 root.join(p)
481 };
482
483 if abs.exists() {
484 continue;
485 }
486
487 let suggestion = match src {
488 ProjectPathSource::Toml => {
489 format!("update or remove [packages.{name}] in alc.toml")
490 }
491 ProjectPathSource::Local => {
492 format!("alc_pkg_unlink({name:?}) or update [packages.{name}] in alc.local.toml")
493 }
494 };
495
496 unrepairable.push(serde_json::json!({
497 "name": name,
498 "kind": "path_missing",
499 "scope": scope,
500 "reason": format!("declared path does not exist: {}", abs.display()),
501 "suggestion": suggestion,
502 }));
503 }
504}
505
506/// Walk `pkg_dir` and collect physical directories that contain `init.lua` but
507/// are not registered in any of the three authoritative sources:
508/// `installed.json` (manifest), `alc.toml [packages]`, or
509/// `alc.local.toml [packages]`.
510///
511/// # Arguments
512///
513/// * `pkg_dir` — `~/.algocline/packages/` (or the path under test)
514/// * `registered` — set of package names known to any registration source
515/// * `registered_paths` — canonicalized absolute paths declared in
516/// `[packages.x] path = "..."` entries from alc.toml / alc.local.toml; used
517/// to skip false positives where a path-dep points inside `pkg_dir`
518/// * `target_filter` — when `Some(name)`, restrict output to that single name
519///
520/// # Returns
521///
522/// A `Vec<serde_json::Value>` of `unregistered_pkg` bucket entries on success.
523/// Each entry carries `name`, `kind`, `source`, `reason`, and `suggestion`
524/// (array of four strings, Clippy-style multi-line).
525///
526/// # Errors
527///
528/// Returns `Err(String)` if `pkg_dir` exists but cannot be read (any `io::Error`
529/// other than `NotFound`). `NotFound` is treated as empty (no packages installed)
530/// and returns `Ok(vec![])`.
531pub(super) fn collect_unregistered_pkg_dirs(
532 pkg_dir: &Path,
533 registered: &HashSet<String>,
534 registered_paths: &[PathBuf],
535 target_filter: Option<&str>,
536) -> Result<Vec<serde_json::Value>, String> {
537 let read = match std::fs::read_dir(pkg_dir) {
538 Ok(r) => r,
539 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
540 // packages_dir absent == empty, not an error (file absent === empty).
541 return Ok(vec![]);
542 }
543 Err(e) => {
544 return Err(format!(
545 "pkg: failed to read packages_dir at {}: {e}",
546 pkg_dir.display()
547 ));
548 }
549 };
550
551 let mut entries = Vec::new();
552
553 for dir_entry_result in read {
554 let dir_entry = match dir_entry_result {
555 Ok(e) => e,
556 Err(e) => {
557 tracing::warn!(
558 "pkg: skipping unreadable entry in {}: {e}",
559 pkg_dir.display()
560 );
561 continue;
562 }
563 };
564
565 let path = dir_entry.path();
566 let pkg_name = dir_entry.file_name().to_string_lossy().to_string();
567
568 // When a specific target is requested, skip all others.
569 if let Some(target) = target_filter {
570 if target != pkg_name.as_str() {
571 continue;
572 }
573 }
574
575 // Skip if name is already in one of the three registration sources.
576 if registered.contains(&pkg_name) {
577 continue;
578 }
579
580 // Only physical directories with init.lua qualify.
581 let meta = match path.symlink_metadata() {
582 Ok(m) => m,
583 Err(e) => {
584 tracing::warn!("pkg: cannot stat {}: {e}", path.display());
585 continue;
586 }
587 };
588 if !meta.is_dir() {
589 // Symlinks are handled by run_unattached_symlink_pass; skip here.
590 continue;
591 }
592 if !path.join("init.lua").exists() {
593 // Empty or non-package directory — skip (AC-2).
594 continue;
595 }
596
597 // Canonical path comparison: skip if any alc.toml / alc.local.toml
598 // path entry resolves to the same physical directory (AC-4).
599 let canonical_pkg_path = match path.canonicalize() {
600 Ok(c) => c,
601 Err(e) => {
602 return Err(format!(
603 "pkg: failed to canonicalize existing dir {}: {e}",
604 path.display()
605 ));
606 }
607 };
608 if registered_paths.contains(&canonical_pkg_path) {
609 continue;
610 }
611
612 // Build Clippy-style multi-line suggestion (crux constraint: 4 elements,
613 // suggestion field is array<string> for unregistered_pkg only).
614 let abs_path = path.display().to_string();
615 let suggestion = serde_json::json!([
616 format!(
617 "If this pkg was scaffolded outside `alc_pkg_scaffold` and you want it installed: \
618 `alc_pkg_install --force {abs_path}` (re-copy + register in installed.json)"
619 ),
620 format!(
621 "If you are actively iterating on this pkg in-tree: \
622 `alc_pkg_link {abs_path}` (symlink-based, no copy)"
623 ),
624 format!("If this dir is stale/abandoned: `rm -rf {abs_path}` to clean it up"),
625 "Note: source is unknown — git URL cannot be inferred from the bare directory. \
626 Re-record via one of the above."
627 .to_string(),
628 ]);
629
630 entries.push(serde_json::json!({
631 "name": pkg_name,
632 "kind": "unregistered_pkg",
633 "source": "unknown",
634 "reason": format!(
635 "physical dir with init.lua exists but is not registered in \
636 installed.json, alc.toml, or alc.local.toml: {}",
637 path.display()
638 ),
639 "suggestion": suggestion,
640 }));
641 }
642
643 Ok(entries)
644}