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::predicate::{DefaultPredicateEnv, eval};
18use crate::tool_config::ToolConfig;
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(tag = "status", content = "detail", rename_all = "snake_case")]
25pub enum CheckStatus<T: Serialize> {
26 Ok(T),
28 Warn(String),
30 Fail(String),
32 NotApplicable(String),
34}
35
36impl<T: Serialize> CheckStatus<T> {
37 pub fn is_ok(&self) -> bool {
39 matches!(self, Self::Ok(_))
40 }
41
42 pub fn needs_attention(&self) -> bool {
44 matches!(self, CheckStatus::Warn(_) | CheckStatus::Fail(_))
45 }
46
47 pub fn sigil(&self) -> char {
49 match self {
50 CheckStatus::Ok(_) => '✓',
51 CheckStatus::Warn(_) => '!',
52 CheckStatus::Fail(_) => '✗',
53 CheckStatus::NotApplicable(_) => '-',
54 }
55 }
56}
57
58pub struct DoctorOpts {
62 pub tool_config_path: PathBuf,
64 pub config_path: Option<PathBuf>,
66 pub manifest_path: PathBuf,
68 pub repo_path: Option<PathBuf>,
70 pub detected_manager: Option<String>,
73}
74
75#[derive(Debug, Serialize, Deserialize)]
82pub struct DoctorReport {
83 pub tool_version: String,
85 pub tool_config: CheckStatus<String>,
87 pub repo_path: CheckStatus<String>,
89 pub repo_is_git: CheckStatus<String>,
91 pub working_tree: CheckStatus<String>,
93 pub krypt_config: CheckStatus<String>,
95 pub link_sources: CheckStatus<String>,
97 pub link_destinations: CheckStatus<String>,
99 pub manifest: CheckStatus<String>,
101 pub platform: CheckStatus<String>,
103 pub package_manager: CheckStatus<String>,
105 pub hooks: CheckStatus<String>,
107}
108
109impl DoctorReport {
110 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 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
183pub fn doctor(opts: &DoctorOpts) -> DoctorReport {
191 let tool_version = env!("CARGO_PKG_VERSION").to_owned();
192
193 let (tool_config_check, tool_cfg) = check_tool_config(&opts.tool_config_path);
195
196 let resolved_repo = opts
198 .repo_path
199 .clone()
200 .or_else(|| tool_cfg.as_ref().map(|tc| tc.repo.path.clone()));
201
202 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 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 let working_tree_check = check_working_tree(gix_repo.as_ref());
233
234 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 let link_sources_check = check_link_sources(krypt_cfg.as_ref(), resolved_repo.as_deref());
244
245 let link_destinations_check = check_link_destinations(&opts.manifest_path);
247
248 let manifest_check = check_manifest(&opts.manifest_path);
250
251 let platform_check = CheckStatus::Ok(Platform::current().as_str().to_owned());
253
254 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
276fn 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 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 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
489fn 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#[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}