1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(tag = "status", content = "detail", rename_all = "snake_case")]
24pub enum CheckStatus<T: Serialize> {
25 Ok(T),
27 Warn(String),
29 Fail(String),
31 NotApplicable(String),
33}
34
35impl<T: Serialize> CheckStatus<T> {
36 pub fn is_ok(&self) -> bool {
38 matches!(self, Self::Ok(_))
39 }
40
41 pub fn needs_attention(&self) -> bool {
43 matches!(self, CheckStatus::Warn(_) | CheckStatus::Fail(_))
44 }
45
46 pub fn sigil(&self) -> char {
48 match self {
49 CheckStatus::Ok(_) => '✓',
50 CheckStatus::Warn(_) => '!',
51 CheckStatus::Fail(_) => '✗',
52 CheckStatus::NotApplicable(_) => '-',
53 }
54 }
55}
56
57pub struct DoctorOpts {
61 pub tool_config_path: PathBuf,
63 pub config_path: Option<PathBuf>,
65 pub manifest_path: PathBuf,
67 pub repo_path: Option<PathBuf>,
69 pub detected_manager: Option<String>,
72}
73
74#[derive(Debug, Serialize, Deserialize)]
81pub struct DoctorReport {
82 pub tool_version: String,
84 pub tool_config: CheckStatus<String>,
86 pub repo_path: CheckStatus<String>,
88 pub repo_is_git: CheckStatus<String>,
90 pub working_tree: CheckStatus<String>,
92 pub krypt_config: CheckStatus<String>,
94 pub link_sources: CheckStatus<String>,
96 pub link_destinations: CheckStatus<String>,
98 pub manifest: CheckStatus<String>,
100 pub platform: CheckStatus<String>,
102 pub package_manager: CheckStatus<String>,
104 pub hooks: CheckStatus<String>,
106}
107
108impl DoctorReport {
109 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 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
182pub fn doctor(opts: &DoctorOpts) -> DoctorReport {
190 let tool_version = env!("CARGO_PKG_VERSION").to_owned();
191
192 let (tool_config_check, tool_cfg) = check_tool_config(&opts.tool_config_path);
194
195 let resolved_repo = opts
197 .repo_path
198 .clone()
199 .or_else(|| tool_cfg.as_ref().map(|tc| tc.repo.path.clone()));
200
201 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 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 let working_tree_check = check_working_tree(gix_repo.as_ref());
232
233 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 let link_sources_check = check_link_sources(krypt_cfg.as_ref(), resolved_repo.as_deref());
243
244 let link_destinations_check = check_link_destinations(&opts.manifest_path);
246
247 let manifest_check = check_manifest(&opts.manifest_path);
249
250 let platform_check = CheckStatus::Ok(Platform::current().as_str().to_owned());
252
253 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
275fn 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
436fn 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#[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}