algocline_app/service/pkg/
doctor.rs1use 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
40enum DoctorOutcome {
42 Healthy,
44 SymlinkDangling { reason: String, suggestion: String },
46 InstalledMissing { reason: String, suggestion: String },
48}
49
50#[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 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
81fn 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
105fn 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
130fn 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 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
175fn 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
197fn 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
211fn 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 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 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 #[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 #[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}