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::{infer_from_legacy_source_string, 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/// match `pkg_repair`'s 3-way routing (repair.rs:238-260):
83///
84/// - `Git` / `Installed` (local path) → `alc_pkg_install(...)`
85/// - `Bundled` → `alc_init` (bundled packages cannot be reinstalled via
86///   `alc_pkg_install`; they ship inside the algocline binary)
87/// - `Path` → edit `alc.toml` / `alc.local.toml` directly (path sources
88///   aren't tracked for reinstall; today `infer_from_legacy_source_string`
89///   never emits this, but the arm is kept as a defensive guard that
90///   mirrors `repair.rs`)
91fn installed_missing_suggestion(name: &str, entry_source: &str) -> String {
92    match infer_from_legacy_source_string(entry_source) {
93        PackageSource::Bundled { .. } => {
94            "alc_init (reinstalls bundled packages from the algocline binary)".to_string()
95        }
96        PackageSource::Path { path } => {
97            format!("edit [packages.{name}] in alc.toml or alc.local.toml (path source: {path})")
98        }
99        PackageSource::Installed | PackageSource::Git { .. } => {
100            format!("alc_pkg_install({name:?}) to reinstall from source ({entry_source})")
101        }
102    }
103}
104
105/// Push a manifest-pass outcome into the appropriate bucket.
106fn push_doctor_outcome(name: &str, outcome: DoctorOutcome, buckets: &mut DoctorBuckets) {
107    match outcome {
108        DoctorOutcome::Healthy => buckets.healthy.push(serde_json::json!({
109            "name": name,
110        })),
111        DoctorOutcome::SymlinkDangling { reason, suggestion } => {
112            buckets.symlink_dangling.push(serde_json::json!({
113                "name": name,
114                "kind": "symlink_dangling",
115                "reason": reason,
116                "suggestion": suggestion,
117            }))
118        }
119        DoctorOutcome::InstalledMissing { reason, suggestion } => {
120            buckets.installed_missing.push(serde_json::json!({
121                "name": name,
122                "kind": "installed_missing",
123                "reason": reason,
124                "suggestion": suggestion,
125            }))
126        }
127    }
128}
129
130/// Classify a manifest entry by inspecting only the destination directory.
131/// Mirrors the pre-install branch of [`super::repair::repair_installed`] but
132/// never attempts an install.
133fn classify_installed(name: &str, entry: &ManifestEntry, pkg_dir: &Path) -> DoctorOutcome {
134    let dest = pkg_dir.join(name);
135
136    let is_symlink = dest
137        .symlink_metadata()
138        .map(|m| m.file_type().is_symlink())
139        .unwrap_or(false);
140    if is_symlink {
141        // `try_exists` follows the symlink — true iff target is alive.
142        let target_alive = match dest.try_exists() {
143            Ok(v) => v,
144            Err(e) => {
145                warn!(error = %e, path = %dest.display(), "try_exists failed; treating symlink target as dead");
146                false
147            }
148        };
149        if target_alive {
150            return DoctorOutcome::Healthy;
151        }
152        let link_target = match dest.read_link() {
153            Ok(t) => t.display().to_string(),
154            Err(e) => {
155                warn!(error = %e, path = %dest.display(), "read_link failed; using placeholder for dangling target");
156                "<unknown>".to_string()
157            }
158        };
159        return DoctorOutcome::SymlinkDangling {
160            reason: format!("symlink target missing: {link_target}"),
161            suggestion: symlink_dangling_suggestion(name),
162        };
163    }
164
165    if dest.exists() {
166        return DoctorOutcome::Healthy;
167    }
168
169    DoctorOutcome::InstalledMissing {
170        reason: format!("installed directory missing: {}", dest.display()),
171        suggestion: installed_missing_suggestion(name, &entry.source),
172    }
173}
174
175/// Classify every manifest entry into the four buckets. When `target_filter`
176/// is `Some(name)`, look the entry up directly (O(log N) on BTreeMap) instead
177/// of scanning the full map.
178fn run_manifest_pass(
179    manifest: &Manifest,
180    target_filter: Option<&str>,
181    pkg_dir: &Path,
182    buckets: &mut DoctorBuckets,
183) {
184    if let Some(target) = target_filter {
185        if let Some(entry) = manifest.packages.get(target) {
186            let outcome = classify_installed(target, entry, pkg_dir);
187            push_doctor_outcome(target, outcome, buckets);
188        }
189        return;
190    }
191    for (pkg_name, entry) in &manifest.packages {
192        let outcome = classify_installed(pkg_name, entry, pkg_dir);
193        push_doctor_outcome(pkg_name, outcome, buckets);
194    }
195}
196
197/// Drain the unattached-symlink scan results into the `symlink_dangling`
198/// bucket. The shared helper writes tagged entries into a scratch vec so
199/// its signature can stay aligned with `pkg_repair`'s unrepairable bucket.
200fn run_unattached_symlink_pass(
201    pkg_dir: &Path,
202    target_filter: Option<&str>,
203    manifest: &Manifest,
204    buckets: &mut DoctorBuckets,
205) {
206    let mut scratch: Vec<serde_json::Value> = Vec::new();
207    collect_unattached_dangling_symlinks(pkg_dir, target_filter, &manifest.packages, &mut scratch);
208    buckets.symlink_dangling.extend(scratch);
209}
210
211/// Scan `alc.toml` + `alc.local.toml` for declared paths that no longer
212/// resolve. `resolved_root = None` means no project context was located,
213/// which mirrors `pkg_repair`'s skip-on-missing behavior.
214fn run_path_missing_pass(
215    resolved_root: Option<&Path>,
216    target_filter: Option<&str>,
217    buckets: &mut DoctorBuckets,
218) {
219    let Some(root) = resolved_root else {
220        return;
221    };
222    let mut scratch: Vec<serde_json::Value> = Vec::new();
223    collect_path_missing(
224        root,
225        target_filter,
226        "project",
227        &mut scratch,
228        ProjectPathSource::Toml,
229    );
230    collect_path_missing(
231        root,
232        target_filter,
233        "variant",
234        &mut scratch,
235        ProjectPathSource::Local,
236    );
237    buckets.path_missing.extend(scratch);
238}
239
240impl AppService {
241    /// Diagnose package state without any side effects. Returns a JSON string
242    /// with four arrays (`healthy`, `installed_missing`, `symlink_dangling`,
243    /// `path_missing`).
244    ///
245    /// `name` restricts the report to a single package; `None` inspects every
246    /// known package. `project_root` is only consulted for the
247    /// `alc.toml` / `alc.local.toml` pass. Falls back to ancestor walk from
248    /// cwd when `None`.
249    ///
250    /// Error surface matches `pkg_repair`:
251    /// - `load_manifest()` / `packages_dir()` failures propagate via `?`.
252    /// - Per-entry `fs::read_dir` errors inside the unattached-symlink scan
253    ///   are logged via `tracing::warn!` and skipped (helper's behavior).
254    /// - When `name = Some(target)` and every bucket ends empty, returns
255    ///   `Err` with the same wording used by `pkg_repair`.
256    pub async fn pkg_doctor(
257        &self,
258        name: Option<String>,
259        project_root: Option<String>,
260    ) -> Result<String, String> {
261        let manifest = load_manifest()?;
262        let pkg_dir = packages_dir()?;
263        let resolved_root = resolve_project_root(project_root.as_deref());
264        let target_filter = name.as_deref();
265
266        let mut buckets = DoctorBuckets::default();
267        run_manifest_pass(&manifest, target_filter, &pkg_dir, &mut buckets);
268        run_unattached_symlink_pass(&pkg_dir, target_filter, &manifest, &mut buckets);
269        run_path_missing_pass(resolved_root.as_deref(), target_filter, &mut buckets);
270
271        if let Some(target) = target_filter {
272            if !buckets.any_matched() {
273                return Err(format!(
274                    "Package '{target}' not found in installed.json, ~/.algocline/packages/, alc.toml, or alc.local.toml"
275                ));
276            }
277        }
278
279        Ok(buckets.into_json())
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use std::path::PathBuf;
287
288    fn mk_entry(source: &str) -> ManifestEntry {
289        ManifestEntry {
290            version: None,
291            source: source.to_string(),
292            installed_at: "2026-01-01T00:00:00Z".to_string(),
293            updated_at: "2026-01-01T00:00:00Z".to_string(),
294        }
295    }
296
297    #[test]
298    fn classify_installed_healthy_dir() {
299        let tmp = tempfile::tempdir().unwrap();
300        let pkg_dir = tmp.path();
301        std::fs::create_dir(pkg_dir.join("p")).unwrap();
302
303        let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir);
304        assert!(matches!(outcome, DoctorOutcome::Healthy));
305    }
306
307    #[test]
308    fn classify_installed_missing_dir() {
309        let tmp = tempfile::tempdir().unwrap();
310        let pkg_dir = tmp.path();
311
312        let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir);
313        match outcome {
314            DoctorOutcome::InstalledMissing { reason, suggestion } => {
315                assert!(
316                    reason.contains("installed directory missing"),
317                    "reason = {reason}"
318                );
319                assert!(
320                    suggestion.contains("alc_pkg_install"),
321                    "suggestion = {suggestion}"
322                );
323                assert!(
324                    suggestion.contains("/src/p"),
325                    "suggestion carries source: {suggestion}"
326                );
327            }
328            _ => panic!("expected InstalledMissing"),
329        }
330    }
331
332    #[test]
333    #[cfg(unix)]
334    fn classify_installed_symlink_dangling() {
335        use std::os::unix::fs::symlink;
336
337        let tmp = tempfile::tempdir().unwrap();
338        let pkg_dir = tmp.path();
339        let dangling_target = PathBuf::from("/nonexistent/path/for/doctor_test");
340        symlink(&dangling_target, pkg_dir.join("p")).unwrap();
341
342        let outcome = classify_installed("p", &mk_entry("/src/p"), pkg_dir);
343        match outcome {
344            DoctorOutcome::SymlinkDangling { reason, suggestion } => {
345                assert!(reason.contains("symlink target missing"), "{reason}");
346                assert!(suggestion.contains("alc_pkg_unlink"), "{suggestion}");
347            }
348            _ => panic!("expected SymlinkDangling"),
349        }
350    }
351
352    #[test]
353    #[cfg(unix)]
354    fn classify_installed_symlink_alive() {
355        use std::os::unix::fs::symlink;
356
357        let tmp = tempfile::tempdir().unwrap();
358        let real_target = tmp.path().join("real_target_dir");
359        std::fs::create_dir(&real_target).unwrap();
360
361        let pkg_dir = tmp.path().join("pkgs");
362        std::fs::create_dir(&pkg_dir).unwrap();
363        symlink(&real_target, pkg_dir.join("q")).unwrap();
364
365        let outcome = classify_installed("q", &mk_entry("/src/q"), &pkg_dir);
366        assert!(matches!(outcome, DoctorOutcome::Healthy));
367    }
368
369    #[test]
370    fn buckets_into_json_emits_all_four_keys() {
371        // NOTE: `serde_json` without the `preserve_order` feature emits JSON
372        // object keys in alphabetical order, matching `pkg_repair`'s actual
373        // behavior. The spec's "fixed order" requirement is satisfied by
374        // always emitting these four top-level keys; consumers parse as a
375        // Map rather than relying on textual key order.
376        let mut b = DoctorBuckets::default();
377        b.healthy.push(serde_json::json!({"name": "h"}));
378        b.installed_missing
379            .push(serde_json::json!({"name": "i", "kind": "installed_missing"}));
380        b.symlink_dangling
381            .push(serde_json::json!({"name": "s", "kind": "symlink_dangling"}));
382        b.path_missing
383            .push(serde_json::json!({"name": "p", "kind": "path_missing"}));
384
385        let out = b.into_json();
386        let parsed: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
387        let obj = parsed.as_object().expect("JSON object");
388        assert!(obj.contains_key("healthy"));
389        assert!(obj.contains_key("installed_missing"));
390        assert!(obj.contains_key("symlink_dangling"));
391        assert!(obj.contains_key("path_missing"));
392        assert_eq!(obj.len(), 4, "exactly four top-level buckets: {out}");
393
394        assert_eq!(obj["healthy"][0]["name"], "h");
395        assert_eq!(obj["installed_missing"][0]["name"], "i");
396        assert_eq!(obj["symlink_dangling"][0]["name"], "s");
397        assert_eq!(obj["path_missing"][0]["name"], "p");
398    }
399
400    #[test]
401    fn any_matched_tracks_all_buckets() {
402        let mut b = DoctorBuckets::default();
403        assert!(!b.any_matched());
404        b.healthy.push(serde_json::json!({"name": "h"}));
405        assert!(b.any_matched());
406
407        let mut b = DoctorBuckets::default();
408        b.installed_missing.push(serde_json::json!({}));
409        assert!(b.any_matched());
410
411        let mut b = DoctorBuckets::default();
412        b.symlink_dangling.push(serde_json::json!({}));
413        assert!(b.any_matched());
414
415        let mut b = DoctorBuckets::default();
416        b.path_missing.push(serde_json::json!({}));
417        assert!(b.any_matched());
418    }
419
420    #[test]
421    fn installed_missing_suggestion_shape() {
422        let s = installed_missing_suggestion("ucb", "github.com/foo/bar");
423        assert!(s.contains("alc_pkg_install"), "{s}");
424        assert!(s.contains("\"ucb\""), "{s}");
425        assert!(s.contains("github.com/foo/bar"), "{s}");
426    }
427
428    /// A bundled-source entry must route the user to `alc_init`, NOT
429    /// `alc_pkg_install("bundled")` (which would fail — bundled packages
430    /// ship inside the algocline binary and are restored via `alc_init`).
431    /// Mirrors `repair.rs:238-260`.
432    #[test]
433    fn installed_missing_suggestion_routes_bundled_to_alc_init() {
434        let s = installed_missing_suggestion("ucb", "bundled");
435        assert!(s.contains("alc_init"), "bundled must suggest alc_init: {s}");
436        assert!(
437            !s.contains("alc_pkg_install"),
438            "bundled must NOT suggest alc_pkg_install: {s}"
439        );
440    }
441
442    /// A bare local absolute path classifies as `Installed` (syntactic), so
443    /// the suggestion falls through to `alc_pkg_install` with that path as
444    /// the source — matching repair's LocalPath installer route.
445    #[test]
446    fn installed_missing_suggestion_routes_absolute_path_to_pkg_install() {
447        let s = installed_missing_suggestion("local_pkg", "/abs/path/to/src");
448        assert!(s.contains("alc_pkg_install"), "{s}");
449        assert!(s.contains("/abs/path/to/src"), "{s}");
450    }
451}