Skip to main content

algocline_app/service/pkg/
doctor.rs

1//! `pkg_doctor` — read-only diagnosis for package state (Wave 2 of local-first DX).
2//!
3//! The actuator counterpart is [`super::repair`] (`pkg_repair`). `pkg_doctor`
4//! classifies packages into four buckets without touching the filesystem:
5//!
6//! | Bucket              | Source-of-truth                                         | Condition                                          |
7//! |---------------------|---------------------------------------------------------|----------------------------------------------------|
8//! | `healthy`           | `installed.json` + `~/.algocline/packages/{name}`       | dest directory exists (resolved through symlinks)  |
9//! | `installed_missing` | `installed.json`                                        | dest missing (non-symlink), `pkg_install` can heal |
10//! | `symlink_dangling`  | `installed.json` (manifest-pass) + filesystem scan      | dest is a symlink whose target is missing          |
11//! | `path_missing`      | `alc.toml` / `alc.local.toml`                           | declared `path = ...` does not exist               |
12//!
13//! Contract:
14//! - **No side effects.** No `fs::write`, `fs::remove_*`, `fs::create_*`,
15//!   symlink operations, or `pkg_install`. Filesystem is read-only.
16//! - Reuses [`super::repair`]'s `pub(super)` helpers to keep the classification
17//!   logic authoritative in one place (symlink-dangling suggestion wording and
18//!   the path-missing scan in particular).
19//!
20//! The JSON output schema always contains these four top-level buckets:
21//! `healthy`, `installed_missing`, `symlink_dangling`, `path_missing`. Key
22//! order within the serialized string follows `serde_json`'s default
23//! (alphabetical when `preserve_order` is off, as it is in this workspace) —
24//! the contract is "these four keys always present", not textual ordering.
25
26use std::path::Path;
27
28use tracing::warn;
29
30use super::super::manifest::{load_manifest, Manifest, ManifestEntry};
31use super::super::project::resolve_project_root;
32use super::super::resolve::packages_dir;
33use super::super::source::PackageSource;
34use super::super::AppService;
35use super::repair::{
36    collect_path_missing, collect_unattached_dangling_symlinks, symlink_dangling_suggestion,
37    ProjectPathSource,
38};
39
40/// Classification of a single manifest-tracked package (read-only).
41enum DoctorOutcome {
42    /// Destination exists and is reachable — no action required.
43    Healthy,
44    /// Destination is a symlink whose target is missing.
45    SymlinkDangling { reason: String, suggestion: String },
46    /// Destination is missing (non-symlink). Install can heal from `source`.
47    InstalledMissing { reason: String, suggestion: String },
48}
49
50/// Accumulator for the four JSON output buckets.
51#[derive(Default)]
52struct DoctorBuckets {
53    healthy: Vec<serde_json::Value>,
54    installed_missing: Vec<serde_json::Value>,
55    symlink_dangling: Vec<serde_json::Value>,
56    path_missing: Vec<serde_json::Value>,
57}
58
59impl DoctorBuckets {
60    fn any_matched(&self) -> bool {
61        !self.healthy.is_empty()
62            || !self.installed_missing.is_empty()
63            || !self.symlink_dangling.is_empty()
64            || !self.path_missing.is_empty()
65    }
66
67    fn into_json(self) -> String {
68        // All four buckets are always emitted (empty arrays when no entries).
69        // `serde_json::json!` serializes keys alphabetically without the
70        // `preserve_order` feature — consumers parse as a Map, not by order.
71        serde_json::json!({
72            "healthy": self.healthy,
73            "installed_missing": self.installed_missing,
74            "symlink_dangling": self.symlink_dangling,
75            "path_missing": self.path_missing,
76        })
77        .to_string()
78    }
79}
80
81/// Suggestion string for `installed_missing`, branched by source kind to
82/// mirror `pkg_repair`'s routing:
83///
84/// - `Git` → `alc_pkg_install(<url>)`
85/// - `Path` → `alc_pkg_install(<path>)` (local re-copy)
86/// - `Bundled` → `alc_init` (bundled packages cannot be reinstalled via
87///   `alc_pkg_install`; they ship inside the algocline binary)
88/// - `Installed` → legacy marker with no re-fetch info (user must re-record
89///   source via `alc_pkg_install`)
90/// - `Unknown` → reindex + reinstall (pre-typed manifest with no source)
91fn installed_missing_suggestion(name: &str, entry_source: &PackageSource) -> String {
92    match entry_source {
93        PackageSource::Bundled { .. } => {
94            "alc_init (reinstalls bundled packages from the algocline binary)".to_string()
95        }
96        PackageSource::Path { path } => {
97            format!("alc_pkg_install({path:?}) to reinstall {name:?} from local path")
98        }
99        PackageSource::Git { url, .. } => {
100            format!("alc_pkg_install({url:?}) to reinstall {name:?} from Git")
101        }
102        PackageSource::Installed => {
103            format!(
104                "alc_pkg_install <path-or-url> to re-record source for {name:?} \
105                 (legacy 'installed' marker carries no path)"
106            )
107        }
108        PackageSource::Unknown => {
109            format!(
110                "alc_hub_reindex then alc_pkg_install <path-or-url> for {name:?} \
111                 (source unknown — legacy entry)"
112            )
113        }
114    }
115}
116
117/// Push a manifest-pass outcome into the appropriate bucket.
118fn push_doctor_outcome(name: &str, outcome: DoctorOutcome, buckets: &mut DoctorBuckets) {
119    match outcome {
120        DoctorOutcome::Healthy => buckets.healthy.push(serde_json::json!({
121            "name": name,
122        })),
123        DoctorOutcome::SymlinkDangling { reason, suggestion } => {
124            buckets.symlink_dangling.push(serde_json::json!({
125                "name": name,
126                "kind": "symlink_dangling",
127                "reason": reason,
128                "suggestion": suggestion,
129            }))
130        }
131        DoctorOutcome::InstalledMissing { reason, suggestion } => {
132            buckets.installed_missing.push(serde_json::json!({
133                "name": name,
134                "kind": "installed_missing",
135                "reason": reason,
136                "suggestion": suggestion,
137            }))
138        }
139    }
140}
141
142/// Classify a manifest entry by inspecting only the destination directory.
143/// Mirrors the pre-install branch of [`super::repair::repair_installed`] but
144/// never attempts an install.
145fn classify_installed(name: &str, entry: &ManifestEntry, pkg_dir: &Path) -> DoctorOutcome {
146    let dest = pkg_dir.join(name);
147
148    let is_symlink = dest
149        .symlink_metadata()
150        .map(|m| m.file_type().is_symlink())
151        .unwrap_or(false);
152    if is_symlink {
153        // `try_exists` follows the symlink — true iff target is alive.
154        let target_alive = match dest.try_exists() {
155            Ok(v) => v,
156            Err(e) => {
157                warn!(error = %e, path = %dest.display(), "try_exists failed; treating symlink target as dead");
158                false
159            }
160        };
161        if target_alive {
162            return DoctorOutcome::Healthy;
163        }
164        let link_target = match dest.read_link() {
165            Ok(t) => t.display().to_string(),
166            Err(e) => {
167                warn!(error = %e, path = %dest.display(), "read_link failed; using placeholder for dangling target");
168                "<unknown>".to_string()
169            }
170        };
171        return DoctorOutcome::SymlinkDangling {
172            reason: format!("symlink target missing: {link_target}"),
173            suggestion: symlink_dangling_suggestion(name),
174        };
175    }
176
177    if dest.exists() {
178        return DoctorOutcome::Healthy;
179    }
180
181    DoctorOutcome::InstalledMissing {
182        reason: format!("installed directory missing: {}", dest.display()),
183        suggestion: installed_missing_suggestion(name, &entry.source),
184    }
185}
186
187/// Classify every manifest entry into the four buckets. When `target_filter`
188/// is `Some(name)`, look the entry up directly (O(log N) on BTreeMap) instead
189/// of scanning the full map.
190fn run_manifest_pass(
191    manifest: &Manifest,
192    target_filter: Option<&str>,
193    pkg_dir: &Path,
194    buckets: &mut DoctorBuckets,
195) {
196    if let Some(target) = target_filter {
197        if let Some(entry) = manifest.packages.get(target) {
198            let outcome = classify_installed(target, entry, pkg_dir);
199            push_doctor_outcome(target, outcome, buckets);
200        }
201        return;
202    }
203    for (pkg_name, entry) in &manifest.packages {
204        let outcome = classify_installed(pkg_name, entry, pkg_dir);
205        push_doctor_outcome(pkg_name, outcome, buckets);
206    }
207}
208
209/// Drain the unattached-symlink scan results into the `symlink_dangling`
210/// bucket. The shared helper writes tagged entries into a scratch vec so
211/// its signature can stay aligned with `pkg_repair`'s unrepairable bucket.
212fn run_unattached_symlink_pass(
213    pkg_dir: &Path,
214    target_filter: Option<&str>,
215    manifest: &Manifest,
216    buckets: &mut DoctorBuckets,
217) {
218    let mut scratch: Vec<serde_json::Value> = Vec::new();
219    collect_unattached_dangling_symlinks(pkg_dir, target_filter, &manifest.packages, &mut scratch);
220    buckets.symlink_dangling.extend(scratch);
221}
222
223/// Scan `alc.toml` + `alc.local.toml` for declared paths that no longer
224/// resolve. `resolved_root = None` means no project context was located,
225/// which mirrors `pkg_repair`'s skip-on-missing behavior.
226fn run_path_missing_pass(
227    resolved_root: Option<&Path>,
228    target_filter: Option<&str>,
229    buckets: &mut DoctorBuckets,
230) {
231    let Some(root) = resolved_root else {
232        return;
233    };
234    let mut scratch: Vec<serde_json::Value> = Vec::new();
235    collect_path_missing(
236        root,
237        target_filter,
238        "project",
239        &mut scratch,
240        ProjectPathSource::Toml,
241    );
242    collect_path_missing(
243        root,
244        target_filter,
245        "variant",
246        &mut scratch,
247        ProjectPathSource::Local,
248    );
249    buckets.path_missing.extend(scratch);
250}
251
252impl AppService {
253    /// Diagnose package state without any side effects. Returns a JSON string
254    /// with four arrays (`healthy`, `installed_missing`, `symlink_dangling`,
255    /// `path_missing`).
256    ///
257    /// `name` restricts the report to a single package; `None` inspects every
258    /// known package. `project_root` is only consulted for the
259    /// `alc.toml` / `alc.local.toml` pass. Falls back to ancestor walk from
260    /// cwd when `None`.
261    ///
262    /// Error surface matches `pkg_repair`:
263    /// - `load_manifest()` / `packages_dir()` failures propagate via `?`.
264    /// - Per-entry `fs::read_dir` errors inside the unattached-symlink scan
265    ///   are logged via `tracing::warn!` and skipped (helper's behavior).
266    /// - When `name = Some(target)` and every bucket ends empty, returns
267    ///   `Err` with the same wording used by `pkg_repair`.
268    pub async fn pkg_doctor(
269        &self,
270        name: Option<String>,
271        project_root: Option<String>,
272    ) -> Result<String, String> {
273        let app_dir = self.log_config.app_dir();
274        let manifest = load_manifest(&app_dir)?;
275        let pkg_dir = packages_dir(&app_dir);
276        let resolved_root = resolve_project_root(project_root.as_deref());
277        let target_filter = name.as_deref();
278
279        let mut buckets = DoctorBuckets::default();
280        run_manifest_pass(&manifest, target_filter, &pkg_dir, &mut buckets);
281        run_unattached_symlink_pass(&pkg_dir, target_filter, &manifest, &mut buckets);
282        run_path_missing_pass(resolved_root.as_deref(), target_filter, &mut buckets);
283
284        if let Some(target) = target_filter {
285            if !buckets.any_matched() {
286                return Err(format!(
287                    "Package '{target}' not found in installed.json, ~/.algocline/packages/, alc.toml, or alc.local.toml"
288                ));
289            }
290        }
291
292        Ok(buckets.into_json())
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use std::path::PathBuf;
300
301    /// Build a minimal `ManifestEntry` with a `PackageSource::Path`.
302    /// Takes a legacy path string so the existing tests keep reading
303    /// naturally; the arg is wrapped into the typed `Path` variant.
304    fn mk_entry(source: &str) -> ManifestEntry {
305        ManifestEntry {
306            version: None,
307            source: PackageSource::Path {
308                path: source.to_string(),
309            },
310            installed_at: "2026-01-01T00:00:00Z".to_string(),
311            updated_at: "2026-01-01T00:00:00Z".to_string(),
312        }
313    }
314
315    #[test]
316    fn classify_installed_healthy_dir() {
317        let tmp = tempfile::tempdir().unwrap();
318        let pkg_dir = tmp.path();
319        std::fs::create_dir(pkg_dir.join("p")).unwrap();
320
321        let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir);
322        assert!(matches!(outcome, DoctorOutcome::Healthy));
323    }
324
325    #[test]
326    fn classify_installed_missing_dir() {
327        let tmp = tempfile::tempdir().unwrap();
328        let pkg_dir = tmp.path();
329
330        let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir);
331        match outcome {
332            DoctorOutcome::InstalledMissing { reason, suggestion } => {
333                assert!(
334                    reason.contains("installed directory missing"),
335                    "reason = {reason}"
336                );
337                assert!(
338                    suggestion.contains("alc_pkg_install"),
339                    "suggestion = {suggestion}"
340                );
341                assert!(
342                    suggestion.contains("/src/p"),
343                    "suggestion carries source: {suggestion}"
344                );
345            }
346            _ => panic!("expected InstalledMissing"),
347        }
348    }
349
350    #[test]
351    #[cfg(unix)]
352    fn classify_installed_symlink_dangling() {
353        use std::os::unix::fs::symlink;
354
355        let tmp = tempfile::tempdir().unwrap();
356        let pkg_dir = tmp.path();
357        let dangling_target = PathBuf::from("/nonexistent/path/for/doctor_test");
358        symlink(&dangling_target, pkg_dir.join("p")).unwrap();
359
360        let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir);
361        match outcome {
362            DoctorOutcome::SymlinkDangling { reason, suggestion } => {
363                assert!(reason.contains("symlink target missing"), "{reason}");
364                assert!(suggestion.contains("alc_pkg_unlink"), "{suggestion}");
365            }
366            _ => panic!("expected SymlinkDangling"),
367        }
368    }
369
370    #[test]
371    #[cfg(unix)]
372    fn classify_installed_symlink_alive() {
373        use std::os::unix::fs::symlink;
374
375        let tmp = tempfile::tempdir().unwrap();
376        let real_target = tmp.path().join("real_target_dir");
377        std::fs::create_dir(&real_target).unwrap();
378
379        let pkg_dir = tmp.path().join("pkgs");
380        std::fs::create_dir(&pkg_dir).unwrap();
381        symlink(&real_target, pkg_dir.join("q")).unwrap();
382
383        let outcome = classify_installed("q", &mk_entry("/src/q"), &pkg_dir);
384        assert!(matches!(outcome, DoctorOutcome::Healthy));
385    }
386
387    #[test]
388    fn buckets_into_json_emits_all_four_keys() {
389        // NOTE: `serde_json` without the `preserve_order` feature emits JSON
390        // object keys in alphabetical order, matching `pkg_repair`'s actual
391        // behavior. The spec's "fixed order" requirement is satisfied by
392        // always emitting these four top-level keys; consumers parse as a
393        // Map rather than relying on textual key order.
394        let mut b = DoctorBuckets::default();
395        b.healthy.push(serde_json::json!({"name": "h"}));
396        b.installed_missing
397            .push(serde_json::json!({"name": "i", "kind": "installed_missing"}));
398        b.symlink_dangling
399            .push(serde_json::json!({"name": "s", "kind": "symlink_dangling"}));
400        b.path_missing
401            .push(serde_json::json!({"name": "p", "kind": "path_missing"}));
402
403        let out = b.into_json();
404        let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
405        let obj = parsed.as_object().expect("JSON object");
406        assert!(obj.contains_key("healthy"));
407        assert!(obj.contains_key("installed_missing"));
408        assert!(obj.contains_key("symlink_dangling"));
409        assert!(obj.contains_key("path_missing"));
410        assert_eq!(obj.len(), 4, "exactly four top-level buckets: {out}");
411
412        assert_eq!(obj["healthy"][0]["name"], "h");
413        assert_eq!(obj["installed_missing"][0]["name"], "i");
414        assert_eq!(obj["symlink_dangling"][0]["name"], "s");
415        assert_eq!(obj["path_missing"][0]["name"], "p");
416    }
417
418    #[test]
419    fn any_matched_tracks_all_buckets() {
420        let mut b = DoctorBuckets::default();
421        assert!(!b.any_matched());
422        b.healthy.push(serde_json::json!({"name": "h"}));
423        assert!(b.any_matched());
424
425        let mut b = DoctorBuckets::default();
426        b.installed_missing.push(serde_json::json!({}));
427        assert!(b.any_matched());
428
429        let mut b = DoctorBuckets::default();
430        b.symlink_dangling.push(serde_json::json!({}));
431        assert!(b.any_matched());
432
433        let mut b = DoctorBuckets::default();
434        b.path_missing.push(serde_json::json!({}));
435        assert!(b.any_matched());
436    }
437
438    #[test]
439    fn installed_missing_suggestion_shape() {
440        let git = PackageSource::Git {
441            url: "github.com/foo/bar".to_string(),
442            rev: None,
443        };
444        let s = installed_missing_suggestion("ucb", &git);
445        assert!(s.contains("alc_pkg_install"), "{s}");
446        assert!(s.contains("\"ucb\""), "{s}");
447        assert!(s.contains("github.com/foo/bar"), "{s}");
448    }
449
450    /// A bundled-source entry must route the user to `alc_init`, NOT
451    /// `alc_pkg_install("bundled")` (which would fail — bundled packages
452    /// ship inside the algocline binary and are restored via `alc_init`).
453    /// Mirrors `repair.rs` bundled arm.
454    #[test]
455    fn installed_missing_suggestion_routes_bundled_to_alc_init() {
456        let bundled = PackageSource::Bundled { collection: None };
457        let s = installed_missing_suggestion("ucb", &bundled);
458        assert!(s.contains("alc_init"), "bundled must suggest alc_init: {s}");
459        assert!(
460            !s.contains("alc_pkg_install"),
461            "bundled must NOT suggest alc_pkg_install: {s}"
462        );
463    }
464
465    /// A `Path` source entry emits a suggestion pointing at
466    /// `alc_pkg_install(<path>)` — matching repair's LocalPath installer
467    /// route. (Under the typed migration, `alc_pkg_install` now records
468    /// local installs as `Path { path }` rather than the legacy
469    /// `Installed` coercion, so this is the canonical local-reinstall
470    /// suggestion.)
471    #[test]
472    fn installed_missing_suggestion_routes_absolute_path_to_pkg_install() {
473        let local = PackageSource::Path {
474            path: "/abs/path/to/src".to_string(),
475        };
476        let s = installed_missing_suggestion("local_pkg", &local);
477        assert!(s.contains("alc_pkg_install"), "{s}");
478        assert!(s.contains("/abs/path/to/src"), "{s}");
479    }
480
481    /// `Unknown` source (legacy pre-typed entry with no recorded source)
482    /// must route the user to `alc_hub_reindex` before attempting a
483    /// reinstall — mirrors the `Unrepairable` routing in `repair.rs`.
484    #[test]
485    fn installed_missing_suggestion_routes_unknown_to_reindex() {
486        let s = installed_missing_suggestion("legacy_pkg", &PackageSource::Unknown);
487        assert!(
488            s.contains("alc_hub_reindex"),
489            "Unknown must suggest alc_hub_reindex: {s}"
490        );
491    }
492}