Skip to main content

krypt_core/
doctor.rs

1//! `krypt doctor` — diagnostic health-check for an install.
2//!
3//! Collects a set of named checks into a [`DoctorReport`] struct, each
4//! represented by a [`CheckStatus`] that can be `Ok`, `Warn`, `Fail`, or
5//! `NotApplicable`.  The report is serde-serializable for `--json` output and
6//! has a human-readable [`DoctorReport::render_text`] method.
7//!
8//! Entry point: [`doctor`].
9
10use std::path::{Path, PathBuf};
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use serde::{Deserialize, Serialize};
14
15use crate::manifest::{DriftStatus, Manifest, detect_drift};
16use crate::paths::Platform;
17use crate::tool_config::ToolConfig;
18
19// ─── CheckStatus ────────────────────────────────────────────────────────────
20
21/// Result of a single diagnostic check.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(tag = "status", content = "detail", rename_all = "snake_case")]
24pub enum CheckStatus<T: Serialize> {
25    /// Check passed; `T` is the associated value.
26    Ok(T),
27    /// Check has a non-fatal concern; message explains what.
28    Warn(String),
29    /// Check failed; message explains what.
30    Fail(String),
31    /// Check does not apply in this context; reason explains why.
32    NotApplicable(String),
33}
34
35impl<T: Serialize> CheckStatus<T> {
36    /// Returns `true` only for `Ok`.
37    pub fn is_ok(&self) -> bool {
38        matches!(self, Self::Ok(_))
39    }
40
41    /// Returns `true` for `Warn` or `Fail` (not `NotApplicable`).
42    pub fn needs_attention(&self) -> bool {
43        matches!(self, CheckStatus::Warn(_) | CheckStatus::Fail(_))
44    }
45
46    /// Single-character sigil for the text renderer.
47    pub fn sigil(&self) -> char {
48        match self {
49            CheckStatus::Ok(_) => '✓',
50            CheckStatus::Warn(_) => '!',
51            CheckStatus::Fail(_) => '✗',
52            CheckStatus::NotApplicable(_) => '-',
53        }
54    }
55}
56
57// ─── DoctorOpts ─────────────────────────────────────────────────────────────
58
59/// Inputs to [`doctor`].
60pub struct DoctorOpts {
61    /// Path to `${XDG_CONFIG}/krypt/config.toml`.
62    pub tool_config_path: PathBuf,
63    /// Override the path to `.krypt.toml`. Derived from tool config when absent.
64    pub config_path: Option<PathBuf>,
65    /// Path to the deployment manifest JSON.
66    pub manifest_path: PathBuf,
67    /// Override the repo path. Derived from tool config when absent.
68    pub repo_path: Option<PathBuf>,
69    /// Detected package manager name, supplied by the CLI layer (krypt-pkg).
70    /// `None` signals that detection was skipped or nothing was found.
71    pub detected_manager: Option<String>,
72}
73
74// ─── DoctorReport ───────────────────────────────────────────────────────────
75
76/// Summary of every diagnostic check.
77///
78/// Serialize this with `serde_json` for machine-readable output, or call
79/// [`DoctorReport::render_text`] for a human-readable report.
80#[derive(Debug, Serialize, Deserialize)]
81pub struct DoctorReport {
82    /// Version of the `krypt` binary.
83    pub tool_version: String,
84    /// Tool config (`${XDG_CONFIG}/krypt/config.toml`) loaded status + path.
85    pub tool_config: CheckStatus<String>,
86    /// Whether the repo path exists on disk.
87    pub repo_path: CheckStatus<String>,
88    /// Whether the repo path is a git repository (via gix).
89    pub repo_is_git: CheckStatus<String>,
90    /// Whether the git working tree is clean.
91    pub working_tree: CheckStatus<String>,
92    /// Whether `.krypt.toml` parses and validates.
93    pub krypt_config: CheckStatus<String>,
94    /// Whether all `[[link]]` src files exist on disk.
95    pub link_sources: CheckStatus<String>,
96    /// Drift status of all deployed `[[link]]` destinations.
97    pub link_destinations: CheckStatus<String>,
98    /// Manifest load status + age.
99    pub manifest: CheckStatus<String>,
100    /// Detected platform.
101    pub platform: CheckStatus<String>,
102    /// Package manager detection (deferred to #19).
103    pub package_manager: CheckStatus<String>,
104    /// Hook runner status (deferred to #43).
105    pub hooks: CheckStatus<String>,
106}
107
108impl DoctorReport {
109    /// Returns `true` when every applicable check is `Ok`.
110    pub fn is_all_green(&self) -> bool {
111        self.tool_config.is_ok()
112            && self.repo_path.is_ok()
113            && self.repo_is_git.is_ok()
114            && self.working_tree.is_ok()
115            && self.krypt_config.is_ok()
116            && self.link_sources.is_ok()
117            && self.link_destinations.is_ok()
118            && self.manifest.is_ok()
119            && self.platform.is_ok()
120    }
121
122    /// Render a single-column human-readable report.
123    pub fn render_text(&self) -> String {
124        let mut lines = Vec::new();
125        lines.push(format!("krypt {}", self.tool_version));
126        lines.push(String::new());
127
128        let rows: Vec<(&str, char, String)> = vec![
129            check_row("tool config", &self.tool_config),
130            check_row("repo path", &self.repo_path),
131            check_row("repo is git", &self.repo_is_git),
132            check_row("working tree", &self.working_tree),
133            check_row("config", &self.krypt_config),
134            check_row("link sources", &self.link_sources),
135            check_row("link destinations", &self.link_destinations),
136            check_row("manifest", &self.manifest),
137            check_row("platform", &self.platform),
138            check_row("package manager", &self.package_manager),
139            check_row("hooks", &self.hooks),
140        ];
141
142        let label_width = rows.iter().map(|(l, _, _)| l.len()).max().unwrap_or(0);
143        let mut attention = 0usize;
144        let applicable = rows.len();
145
146        for (label, sigil, detail) in &rows {
147            lines.push(format!("{sigil} {label:<label_width$}  {detail}"));
148            if *sigil != '✓' && *sigil != '-' {
149                attention += 1;
150            }
151        }
152
153        lines.push(String::new());
154        if attention == 0 {
155            lines.push(format!("all {applicable} checks passed."));
156        } else {
157            lines.push(format!("{attention}/{applicable} checks need attention."));
158        }
159
160        lines.join("\n")
161    }
162}
163
164fn check_row<'a, T: Serialize>(label: &'a str, status: &CheckStatus<T>) -> (&'a str, char, String) {
165    let (sigil, detail) = render_check(status);
166    (label, sigil, detail)
167}
168
169fn render_check<T: Serialize>(status: &CheckStatus<T>) -> (char, String) {
170    let sigil = status.sigil();
171    let detail = match status {
172        CheckStatus::Ok(v) => serde_json::to_value(v)
173            .ok()
174            .and_then(|j| j.as_str().map(str::to_owned))
175            .unwrap_or_else(|| format!("{}", serde_json::to_value(v).unwrap_or_default())),
176        CheckStatus::Warn(m) | CheckStatus::Fail(m) => m.clone(),
177        CheckStatus::NotApplicable(r) => r.clone(),
178    };
179    (sigil, detail)
180}
181
182// ─── doctor ─────────────────────────────────────────────────────────────────
183
184/// Run all diagnostic checks and return a [`DoctorReport`].
185///
186/// This function never panics and never returns `Err`; individual check
187/// failures are captured inside the report.  The caller decides the process
188/// exit code via [`DoctorReport::is_all_green`].
189pub fn doctor(opts: &DoctorOpts) -> DoctorReport {
190    let tool_version = env!("CARGO_PKG_VERSION").to_owned();
191
192    // ── tool config ──────────────────────────────────────────────────────────
193    let (tool_config_check, tool_cfg) = check_tool_config(&opts.tool_config_path);
194
195    // ── derive repo path ─────────────────────────────────────────────────────
196    let resolved_repo = opts
197        .repo_path
198        .clone()
199        .or_else(|| tool_cfg.as_ref().map(|tc| tc.repo.path.clone()));
200
201    // ── repo path exists ─────────────────────────────────────────────────────
202    let (repo_path_check, repo_path_ok) = match &resolved_repo {
203        None => (
204            CheckStatus::Fail("cannot determine repo path — tool config missing".into()),
205            false,
206        ),
207        Some(rp) => {
208            if rp.exists() {
209                (CheckStatus::Ok(rp.display().to_string()), true)
210            } else {
211                (
212                    CheckStatus::Fail(format!("{} does not exist", rp.display())),
213                    false,
214                )
215            }
216        }
217    };
218
219    // ── repo is git ──────────────────────────────────────────────────────────
220    let (repo_is_git_check, gix_repo) = if repo_path_ok {
221        let rp = resolved_repo.as_deref().unwrap();
222        check_git_repo(rp)
223    } else {
224        (
225            CheckStatus::Fail("skipped — repo path not available".into()),
226            None,
227        )
228    };
229
230    // ── working tree clean ───────────────────────────────────────────────────
231    let working_tree_check = check_working_tree(gix_repo.as_ref());
232
233    // ── .krypt.toml parses ───────────────────────────────────────────────────
234    let config_path = opts
235        .config_path
236        .clone()
237        .or_else(|| resolved_repo.as_ref().map(|rp| rp.join(".krypt.toml")));
238
239    let (krypt_config_check, krypt_cfg) = check_krypt_config(config_path.as_deref());
240
241    // ── link sources exist ───────────────────────────────────────────────────
242    let link_sources_check = check_link_sources(krypt_cfg.as_ref(), resolved_repo.as_deref());
243
244    // ── link destinations (drift) ────────────────────────────────────────────
245    let link_destinations_check = check_link_destinations(&opts.manifest_path);
246
247    // ── manifest ─────────────────────────────────────────────────────────────
248    let manifest_check = check_manifest(&opts.manifest_path);
249
250    // ── platform ─────────────────────────────────────────────────────────────
251    let platform_check = CheckStatus::Ok(Platform::current().as_str().to_owned());
252
253    // ── package manager ──────────────────────────────────────────────────────
254    let package_manager_check = match &opts.detected_manager {
255        Some(name) => CheckStatus::Ok(name.clone()),
256        None => CheckStatus::Warn("no package manager detected on PATH".into()),
257    };
258
259    DoctorReport {
260        tool_version,
261        tool_config: tool_config_check,
262        repo_path: repo_path_check,
263        repo_is_git: repo_is_git_check,
264        working_tree: working_tree_check,
265        krypt_config: krypt_config_check,
266        link_sources: link_sources_check,
267        link_destinations: link_destinations_check,
268        manifest: manifest_check,
269        platform: platform_check,
270        package_manager: package_manager_check,
271        hooks: CheckStatus::NotApplicable("pending #43".into()),
272    }
273}
274
275// ─── individual checks ────────────────────────────────────────────────────
276
277fn check_tool_config(path: &Path) -> (CheckStatus<String>, Option<ToolConfig>) {
278    match ToolConfig::load(path) {
279        Ok(Some(cfg)) => (CheckStatus::Ok(path.display().to_string()), Some(cfg)),
280        Ok(None) => (
281            CheckStatus::Fail(format!("not found at {}", path.display())),
282            None,
283        ),
284        Err(e) => (CheckStatus::Fail(format!("load error: {e}")), None),
285    }
286}
287
288fn check_git_repo(repo_path: &Path) -> (CheckStatus<String>, Option<gix::Repository>) {
289    match gix::open(repo_path) {
290        Ok(repo) => {
291            let head = repo
292                .head_commit()
293                .ok()
294                .map(|c| {
295                    let id = c.id;
296                    format!("HEAD {}", &id.to_hex_with_len(7))
297                })
298                .unwrap_or_else(|| "HEAD <unknown>".into());
299            (CheckStatus::Ok(head), Some(repo))
300        }
301        Err(e) => (CheckStatus::Fail(format!("not a git repo: {e}")), None),
302    }
303}
304
305fn check_working_tree(repo: Option<&gix::Repository>) -> CheckStatus<String> {
306    let Some(repo) = repo else {
307        return CheckStatus::Fail("skipped — repo not available".into());
308    };
309    match repo.is_dirty() {
310        Ok(true) => CheckStatus::Warn("uncommitted changes present".into()),
311        Ok(false) => CheckStatus::Ok("clean".into()),
312        Err(e) => CheckStatus::Warn(format!("status check failed: {e}")),
313    }
314}
315
316fn check_krypt_config(
317    config_path: Option<&Path>,
318) -> (CheckStatus<String>, Option<crate::config::Config>) {
319    let Some(path) = config_path else {
320        return (
321            CheckStatus::Fail(
322                "cannot determine config path — tool config and repo path both missing".into(),
323            ),
324            None,
325        );
326    };
327
328    if !path.exists() {
329        return (
330            CheckStatus::Fail(format!("{} not found", path.display())),
331            None,
332        );
333    }
334
335    match crate::include::load_with_includes(path) {
336        Ok(cfg) => {
337            let links = cfg.links.len();
338            let templates = cfg.templates.len();
339            let detail = if templates == 0 {
340                format!("parses, {links} links")
341            } else {
342                format!("parses, {links} links + {templates} templates")
343            };
344            (CheckStatus::Ok(detail), Some(cfg))
345        }
346        Err(e) => (CheckStatus::Fail(format!("parse error: {e}")), None),
347    }
348}
349
350fn check_link_sources(
351    cfg: Option<&crate::config::Config>,
352    repo_path: Option<&Path>,
353) -> CheckStatus<String> {
354    let Some(cfg) = cfg else {
355        return CheckStatus::Fail("skipped — config not loaded".into());
356    };
357    let Some(repo) = repo_path else {
358        return CheckStatus::Fail("skipped — repo path not available".into());
359    };
360
361    let mut missing: Vec<String> = Vec::new();
362    for link in &cfg.links {
363        if let Some(src) = &link.src {
364            let full = repo.join(src);
365            if !full.exists() {
366                missing.push(src.clone());
367            }
368        }
369    }
370
371    let total = cfg.links.iter().filter(|l| l.src.is_some()).count();
372
373    if missing.is_empty() {
374        CheckStatus::Ok(format!("all {total} exist"))
375    } else {
376        CheckStatus::Fail(format!("{} missing: {}", missing.len(), missing.join(", ")))
377    }
378}
379
380fn check_link_destinations(manifest_path: &Path) -> CheckStatus<String> {
381    match Manifest::load(manifest_path) {
382        Ok(None) => CheckStatus::NotApplicable("no manifest — nothing deployed yet".into()),
383        Ok(Some(manifest)) => {
384            let drift = detect_drift(&manifest);
385            let total = drift.len();
386            let drifted = drift
387                .iter()
388                .filter(|d| d.status == DriftStatus::Drifted)
389                .count();
390            let missing = drift
391                .iter()
392                .filter(|d| d.status == DriftStatus::DstMissing)
393                .count();
394            let clean = total - drifted - missing;
395
396            if drifted == 0 && missing == 0 {
397                CheckStatus::Ok(format!("{clean} clean"))
398            } else {
399                let mut parts = Vec::new();
400                if clean > 0 {
401                    parts.push(format!("{clean} clean"));
402                }
403                if drifted > 0 {
404                    parts.push(format!("{drifted} drifted"));
405                }
406                if missing > 0 {
407                    parts.push(format!("{missing} missing"));
408                }
409                CheckStatus::Warn(format!(
410                    "{} (run `krypt diff` for details)",
411                    parts.join(", ")
412                ))
413            }
414        }
415        Err(e) => CheckStatus::Fail(format!("manifest load error: {e}")),
416    }
417}
418
419fn check_manifest(manifest_path: &Path) -> CheckStatus<String> {
420    match Manifest::load(manifest_path) {
421        Ok(None) => CheckStatus::Fail(format!("not found at {}", manifest_path.display())),
422        Ok(Some(manifest)) => {
423            let entries = manifest.entries.len();
424            let age_secs = SystemTime::now()
425                .duration_since(UNIX_EPOCH)
426                .map(|d| d.as_secs())
427                .unwrap_or(0)
428                .saturating_sub(manifest.deployed_at);
429            let age = humanish_age(age_secs);
430            CheckStatus::Ok(format!("{entries} entries, last deploy {age}"))
431        }
432        Err(e) => CheckStatus::Fail(format!("load error: {e}")),
433    }
434}
435
436/// Convert seconds into a simple human-readable string.
437fn humanish_age(secs: u64) -> String {
438    if secs < 60 {
439        return format!("{secs}s ago");
440    }
441    let mins = secs / 60;
442    if mins < 60 {
443        return format!("{mins}m ago");
444    }
445    let hours = mins / 60;
446    if hours < 24 {
447        return format!("{hours}h ago");
448    }
449    let days = hours / 24;
450    format!("{days}d ago")
451}
452
453// ─── Tests ───────────────────────────────────────────────────────────────────
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458    use crate::copy::EntryKind;
459    use crate::manifest::ManifestEntry;
460    use crate::tool_config::RepoConfig;
461    use std::fs;
462    use tempfile::tempdir;
463
464    fn write_commit(repo: &gix::Repository, message: &str, files: &[(&str, &[u8])]) {
465        let mut entries: Vec<gix::objs::tree::Entry> = files
466            .iter()
467            .map(|(name, content)| {
468                let blob_id = repo.write_blob(content).expect("write blob").detach();
469                gix::objs::tree::Entry {
470                    mode: gix::objs::tree::EntryKind::Blob.into(),
471                    filename: (*name).into(),
472                    oid: blob_id,
473                }
474            })
475            .collect();
476        entries.sort_by(|a, b| a.filename.cmp(&b.filename));
477
478        let tree = gix::objs::Tree { entries };
479        let tree_id = repo.write_object(&tree).expect("write tree").detach();
480        let sig = gix::actor::SignatureRef::from_bytes(b"T <t@t> 0 +0000").unwrap();
481        let parent: Vec<gix::hash::ObjectId> = repo
482            .head_id()
483            .ok()
484            .map(|id| id.detach())
485            .into_iter()
486            .collect();
487        repo.commit_as(sig, sig, "HEAD", message, tree_id, parent)
488            .expect("commit");
489    }
490
491    fn init_git_repo(dir: &Path) -> gix::Repository {
492        let repo = gix::init(dir).expect("gix::init");
493        write_commit(&repo, "initial", &[]);
494        repo
495    }
496
497    fn make_tool_config(repo_path: &Path, tc_path: &Path) {
498        let cfg = ToolConfig {
499            repo: RepoConfig {
500                path: repo_path.to_path_buf(),
501                url: None,
502            },
503        };
504        cfg.save(tc_path).unwrap();
505    }
506
507    fn make_krypt_toml(repo: &Path, content: &str) {
508        fs::write(repo.join(".krypt.toml"), content).unwrap();
509    }
510
511    fn fake_manifest_entry(src: &str, dst: PathBuf) -> ManifestEntry {
512        ManifestEntry {
513            src: src.into(),
514            dst,
515            kind: EntryKind::Link,
516            hash_src: "sha256:aa".into(),
517            hash_dst: "sha256:aa".into(),
518            deployed_at: 0,
519        }
520    }
521
522    // ── 1. Healthy synthetic install ─────────────────────────────────────────
523
524    #[test]
525    fn healthy_install_all_green() {
526        let repo_dir = tempdir().unwrap();
527        let tc_dir = tempdir().unwrap();
528        let state_dir = tempdir().unwrap();
529
530        init_git_repo(repo_dir.path());
531
532        let tc_path = tc_dir.path().join("config.toml");
533        make_tool_config(repo_dir.path(), &tc_path);
534
535        let src_file = repo_dir.path().join("dot_gitconfig");
536        fs::write(&src_file, b"[user]").unwrap();
537
538        let dst_file = state_dir.path().join("deployed_gitconfig");
539        fs::write(&dst_file, b"[user]").unwrap();
540        let hash = crate::manifest::hash_file(&dst_file).unwrap();
541
542        let manifest_path = state_dir.path().join("manifest.json");
543        let mut m = Manifest::new(repo_dir.path().to_path_buf());
544        m.record(ManifestEntry {
545            src: "dot_gitconfig".into(),
546            dst: dst_file.clone(),
547            kind: EntryKind::Link,
548            hash_src: hash.clone(),
549            hash_dst: hash,
550            deployed_at: m.deployed_at,
551        });
552        m.save(&manifest_path).unwrap();
553
554        let src_name = src_file
555            .file_name()
556            .unwrap()
557            .to_string_lossy()
558            .replace('\\', "/");
559        let dst_str = dst_file.to_string_lossy().replace('\\', "/");
560        let toml_content = format!("[[link]]\nsrc = \"{src_name}\"\ndst = \"{dst_str}\"\n");
561        make_krypt_toml(repo_dir.path(), &toml_content);
562
563        let report = doctor(&DoctorOpts {
564            tool_config_path: tc_path,
565            config_path: None,
566            manifest_path,
567            repo_path: None,
568            detected_manager: Some("pacman".into()),
569        });
570
571        assert!(
572            report.tool_config.is_ok(),
573            "tool_config: {:?}",
574            report.tool_config
575        );
576        assert!(
577            report.repo_path.is_ok(),
578            "repo_path: {:?}",
579            report.repo_path
580        );
581        assert!(
582            report.repo_is_git.is_ok(),
583            "repo_is_git: {:?}",
584            report.repo_is_git
585        );
586        assert!(
587            report.working_tree.is_ok(),
588            "working_tree: {:?}",
589            report.working_tree
590        );
591        assert!(
592            report.krypt_config.is_ok(),
593            "krypt_config: {:?}",
594            report.krypt_config
595        );
596        assert!(
597            report.link_sources.is_ok(),
598            "link_sources: {:?}",
599            report.link_sources
600        );
601        assert!(
602            report.link_destinations.is_ok(),
603            "link_destinations: {:?}",
604            report.link_destinations
605        );
606        assert!(report.manifest.is_ok(), "manifest: {:?}", report.manifest);
607        assert!(report.is_all_green());
608    }
609
610    // ── 2. Missing tool config ───────────────────────────────────────────────
611
612    #[test]
613    fn missing_tool_config_fails() {
614        let tc_dir = tempdir().unwrap();
615        let state_dir = tempdir().unwrap();
616
617        let report = doctor(&DoctorOpts {
618            tool_config_path: tc_dir.path().join("nonexistent.toml"),
619            config_path: None,
620            manifest_path: state_dir.path().join("manifest.json"),
621            repo_path: None,
622            detected_manager: None,
623        });
624
625        assert!(report.tool_config.needs_attention());
626        assert!(!report.is_all_green());
627    }
628
629    // ── 3. Repo path does not exist ──────────────────────────────────────────
630
631    #[test]
632    fn missing_repo_path_fails() {
633        let tc_dir = tempdir().unwrap();
634        let state_dir = tempdir().unwrap();
635
636        let tc_path = tc_dir.path().join("config.toml");
637        let bogus_repo = tc_dir.path().join("nonexistent_repo");
638        make_tool_config(&bogus_repo, &tc_path);
639
640        let report = doctor(&DoctorOpts {
641            tool_config_path: tc_path,
642            config_path: None,
643            manifest_path: state_dir.path().join("manifest.json"),
644            repo_path: None,
645            detected_manager: None,
646        });
647
648        assert!(report.repo_path.needs_attention());
649        assert!(!report.is_all_green());
650    }
651
652    // ── 4. Repo path exists but is not a git repo ────────────────────────────
653
654    #[test]
655    fn non_git_repo_fails() {
656        let repo_dir = tempdir().unwrap();
657        let tc_dir = tempdir().unwrap();
658        let state_dir = tempdir().unwrap();
659
660        let tc_path = tc_dir.path().join("config.toml");
661        make_tool_config(repo_dir.path(), &tc_path);
662
663        let report = doctor(&DoctorOpts {
664            tool_config_path: tc_path,
665            config_path: None,
666            manifest_path: state_dir.path().join("manifest.json"),
667            repo_path: None,
668            detected_manager: None,
669        });
670
671        assert!(report.repo_path.is_ok());
672        assert!(report.repo_is_git.needs_attention());
673        assert!(!report.is_all_green());
674    }
675
676    // ── 5. Link src missing on disk ──────────────────────────────────────────
677
678    #[test]
679    fn missing_link_src_fails() {
680        let repo_dir = tempdir().unwrap();
681        let tc_dir = tempdir().unwrap();
682        let state_dir = tempdir().unwrap();
683
684        init_git_repo(repo_dir.path());
685        let tc_path = tc_dir.path().join("config.toml");
686        make_tool_config(repo_dir.path(), &tc_path);
687
688        make_krypt_toml(
689            repo_dir.path(),
690            "[[link]]\nsrc = \"does_not_exist\"\ndst = \"/tmp/x\"\n",
691        );
692
693        let report = doctor(&DoctorOpts {
694            tool_config_path: tc_path,
695            config_path: None,
696            manifest_path: state_dir.path().join("manifest.json"),
697            repo_path: None,
698            detected_manager: None,
699        });
700
701        assert!(report.link_sources.needs_attention());
702        if let CheckStatus::Fail(msg) = &report.link_sources {
703            assert!(msg.contains("does_not_exist"), "msg: {msg}");
704        }
705        assert!(!report.is_all_green());
706    }
707
708    // ── 6. Manifest has drifted entry ────────────────────────────────────────
709
710    #[test]
711    fn drifted_manifest_entry_reported() {
712        let repo_dir = tempdir().unwrap();
713        let tc_dir = tempdir().unwrap();
714        let state_dir = tempdir().unwrap();
715
716        init_git_repo(repo_dir.path());
717        let tc_path = tc_dir.path().join("config.toml");
718        make_tool_config(repo_dir.path(), &tc_path);
719        make_krypt_toml(repo_dir.path(), "");
720
721        let dst = state_dir.path().join("deployed.txt");
722        fs::write(&dst, b"changed").unwrap();
723
724        let manifest_path = state_dir.path().join("manifest.json");
725        let mut m = Manifest::new(repo_dir.path().to_path_buf());
726        m.record(ManifestEntry {
727            hash_dst: "sha256:0000000000000000000000000000000000000000000000000000000000000000"
728                .into(),
729            ..fake_manifest_entry("dot", dst)
730        });
731        m.save(&manifest_path).unwrap();
732
733        let report = doctor(&DoctorOpts {
734            tool_config_path: tc_path,
735            config_path: None,
736            manifest_path,
737            repo_path: None,
738            detected_manager: None,
739        });
740
741        assert!(report.link_destinations.needs_attention());
742        if let CheckStatus::Warn(msg) = &report.link_destinations {
743            assert!(msg.contains("drifted"), "msg: {msg}");
744        }
745    }
746
747    // ── 7. JSON output is valid JSON ─────────────────────────────────────────
748
749    #[test]
750    fn json_output_is_valid() {
751        let tc_dir = tempdir().unwrap();
752        let state_dir = tempdir().unwrap();
753
754        let report = doctor(&DoctorOpts {
755            tool_config_path: tc_dir.path().join("config.toml"),
756            config_path: None,
757            manifest_path: state_dir.path().join("manifest.json"),
758            repo_path: None,
759            detected_manager: None,
760        });
761
762        let json = serde_json::to_string_pretty(&report).expect("serialize");
763        let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse back");
764        assert!(parsed.is_object());
765        assert!(parsed["tool_version"].is_string());
766    }
767}