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