1use std::collections::BTreeMap;
13
14use serde::Serialize;
15
16use crate::adapter::{AdapterMetadata, Capability, CapabilityState, SupportLevel};
17use crate::config::Config;
18use crate::doctor::{Report, Status};
19use crate::exe::{ExecutableStatus, Origin};
20use crate::lifecycle::{Listing, Presence};
21use crate::name::AgentId;
22use crate::repo::{Discovery, is_proper_ancestor};
23use crate::repositories::Entry;
24use crate::resolve::{Resolution, ResolutionSource};
25
26pub const TOOL_VERSION: &str = env!("CARGO_PKG_VERSION");
28
29pub fn render<T: Serialize>(document: &T) -> String {
31 let mut text = serde_json::to_string_pretty(document).expect("view types serialise");
32 text.push('\n');
33 text
34}
35
36pub fn source_token(source: ResolutionSource) -> &'static str {
38 match source {
39 ResolutionSource::Explicit => "explicit",
40 ResolutionSource::RepositoryAgent => "repository_agent_mapping",
41 ResolutionSource::RepositoryDefault => "repository_mapping",
42 ResolutionSource::GlobalDefault => "global_default",
43 ResolutionSource::None => "none",
44 }
45}
46
47#[derive(Debug, Serialize)]
49pub struct ResolveDocument {
50 schema: &'static str,
51 tool_version: &'static str,
52 agent: String,
53 profile: Option<String>,
54 source: &'static str,
55 repository: Option<String>,
56}
57
58pub fn resolve(resolution: &Resolution) -> ResolveDocument {
59 ResolveDocument {
60 schema: "agent-profile/resolve/1",
61 tool_version: TOOL_VERSION,
62 agent: resolution.agent.to_string(),
63 profile: resolution.profile.as_ref().map(ToString::to_string),
64 source: source_token(resolution.source),
65 repository: resolution.repository.as_ref().map(|path| path.display().to_string()),
66 }
67}
68
69#[derive(Debug, Serialize)]
71pub struct PresenceView {
72 state: &'static str,
73 existing: Option<String>,
74}
75
76#[derive(Debug, Serialize)]
78pub struct AgentResolutionView {
79 agent: String,
80 profile: Option<String>,
81 source: &'static str,
82 presence: Option<PresenceView>,
83}
84
85#[derive(Debug, Serialize)]
87pub struct MappingView {
88 profile: Option<String>,
89 agents: BTreeMap<String, String>,
90}
91
92#[derive(Debug, Serialize)]
94pub struct StatusDocument {
95 schema: &'static str,
96 tool_version: &'static str,
97 agent: Option<String>,
98 repository: Option<String>,
99 mapping: Option<MappingView>,
100 default_profile: Option<String>,
101 agents: Vec<AgentResolutionView>,
102 inapplicable_mappings: Vec<String>,
103}
104
105pub fn status(
106 agent: Option<&AgentId>,
107 discovery: &Discovery,
108 config: &Config,
109 agents: &[(Resolution, Option<Presence>)],
110) -> StatusDocument {
111 let (repository, mapping, inapplicable_mappings) = match discovery {
112 Discovery::Repository(root) => (
113 Some(root.display().to_string()),
114 config.mapping(root).map(|mapping| MappingView {
115 profile: mapping.profile.as_ref().map(ToString::to_string),
116 agents: mapping
117 .agents
118 .iter()
119 .map(|(id, profile)| (id.to_string(), profile.to_string()))
120 .collect(),
121 }),
122 config
123 .mappings()
124 .filter(|(key, _)| is_proper_ancestor(key, root))
125 .map(|(key, _)| key.display().to_string())
126 .collect(),
127 ),
128 Discovery::NotInRepository => (None, None, Vec::new()),
129 };
130 StatusDocument {
131 schema: "agent-profile/status/1",
132 tool_version: TOOL_VERSION,
133 agent: agent.map(ToString::to_string),
134 repository,
135 mapping,
136 default_profile: config.default_profile().map(ToString::to_string),
137 agents: agents
138 .iter()
139 .map(|(resolution, presence)| AgentResolutionView {
140 agent: resolution.agent.to_string(),
141 profile: resolution.profile.as_ref().map(ToString::to_string),
142 source: source_token(resolution.source),
143 presence: presence.as_ref().map(|presence| PresenceView {
144 state: presence.token(),
145 existing: match presence {
146 Presence::Conflict { existing } => Some(existing.clone()),
147 _ => None,
148 },
149 }),
150 })
151 .collect(),
152 inapplicable_mappings,
153 }
154}
155
156pub fn state_token(state: CapabilityState) -> &'static str {
158 match state {
159 CapabilityState::Supported => "supported",
160 CapabilityState::NotSupported => "not_supported",
161 CapabilityState::NotGuaranteed => "not_guaranteed",
162 CapabilityState::Conditional => "conditional",
163 CapabilityState::Unknown => "unknown",
164 }
165}
166
167pub fn capability_token(capability: Capability) -> &'static str {
169 match capability {
170 Capability::ConfigIsolation => "config",
171 Capability::CredentialIsolation => "credentials",
172 Capability::StateIsolation => "state",
173 }
174}
175
176pub fn support_token(support: SupportLevel) -> &'static str {
178 match support {
179 SupportLevel::Proven => "proven",
180 SupportLevel::Experimental => "experimental",
181 }
182}
183
184#[derive(Debug, Serialize)]
186pub struct ExecutableView {
187 state: &'static str,
188 path: Option<String>,
189 origin: Option<&'static str>,
190}
191
192#[derive(Debug, Serialize)]
194pub struct MechanismView {
195 id: &'static str,
196 description: String,
197}
198
199#[derive(Debug, Serialize)]
201pub struct EvidenceView {
202 upstream_version: &'static str,
203 verified_at: &'static str,
204 source_url: &'static str,
205 notes: &'static str,
206}
207
208#[derive(Debug, Serialize)]
210pub struct CapabilityView {
211 capability: &'static str,
212 state: &'static str,
213 basis: &'static str,
214}
215
216#[derive(Debug, Serialize)]
218pub struct AgentView {
219 agent: &'static str,
220 executable: ExecutableView,
221 mechanism: MechanismView,
222 support: &'static str,
223 evidence: EvidenceView,
224 capabilities: Vec<CapabilityView>,
225}
226
227#[derive(Debug, Serialize)]
229pub struct AgentsDocument {
230 schema: &'static str,
231 tool_version: &'static str,
232 agents: Vec<AgentView>,
233}
234
235pub fn executable_view(status: &ExecutableStatus) -> ExecutableView {
237 match status {
238 ExecutableStatus::Found(found) => ExecutableView {
239 state: "found",
240 path: Some(found.path.display().to_string()),
241 origin: Some(match found.origin {
242 Origin::Configured => "configured",
243 Origin::Path => "path",
244 }),
245 },
246 ExecutableStatus::NotOnPath => {
247 ExecutableView { state: "not_on_path", path: None, origin: None }
248 }
249 ExecutableStatus::ConfiguredMissing(path) => ExecutableView {
250 state: "configured_missing",
251 path: Some(path.display().to_string()),
252 origin: None,
253 },
254 ExecutableStatus::Unsupported(path) => ExecutableView {
255 state: "unsupported",
256 path: Some(path.display().to_string()),
257 origin: None,
258 },
259 }
260}
261
262pub fn agent_view(metadata: &AdapterMetadata, status: &ExecutableStatus) -> AgentView {
264 let capabilities = Capability::ALL
265 .into_iter()
266 .map(|capability| {
267 let claim = metadata
268 .capabilities
269 .iter()
270 .find(|claim| claim.capability == capability)
271 .expect("registry adapters declare every capability");
272 CapabilityView {
273 capability: capability_token(capability),
274 state: state_token(claim.state),
275 basis: claim.basis,
276 }
277 })
278 .collect();
279 AgentView {
280 agent: metadata.id,
281 executable: executable_view(status),
282 mechanism: MechanismView {
283 id: metadata.evidence.mechanism_id,
284 description: metadata.mechanism.to_string(),
285 },
286 support: support_token(metadata.support),
287 evidence: EvidenceView {
288 upstream_version: metadata.evidence.upstream_version,
289 verified_at: metadata.evidence.verified_at,
290 source_url: metadata.evidence.source_url,
291 notes: metadata.evidence.notes,
292 },
293 capabilities,
294 }
295}
296
297pub fn agents(entries: &[(&AdapterMetadata, ExecutableStatus)]) -> AgentsDocument {
299 let agents = entries.iter().map(|(metadata, status)| agent_view(metadata, status)).collect();
300 AgentsDocument { schema: "agent-profile/agents/1", tool_version: TOOL_VERSION, agents }
301}
302
303#[derive(Debug, Serialize)]
305pub struct ProfileView {
306 name: String,
307 agents: Vec<String>,
308}
309
310#[derive(Debug, Serialize)]
312pub struct ProfilesDocument {
313 schema: &'static str,
314 tool_version: &'static str,
315 agent: Option<String>,
316 profiles: Vec<ProfileView>,
317 ignored_entries: usize,
318}
319
320impl ProfilesDocument {
321 pub fn is_empty(&self) -> bool {
323 self.profiles.is_empty()
324 }
325}
326
327pub fn profiles(listing: &Listing, agent: Option<&AgentId>) -> ProfilesDocument {
329 ProfilesDocument {
330 schema: "agent-profile/profiles/1",
331 tool_version: TOOL_VERSION,
332 agent: agent.map(ToString::to_string),
333 profiles: listing
334 .rows
335 .iter()
336 .filter(|(_, ids)| agent.is_none_or(|agent| ids.iter().any(|id| id == agent.as_str())))
337 .map(|(name, ids)| ProfileView { name: name.to_string(), agents: ids.clone() })
338 .collect(),
339 ignored_entries: listing.ignored,
340 }
341}
342
343#[derive(Debug, Serialize)]
345pub struct ProfileRefView {
346 name: String,
347 exists: bool,
348}
349
350#[derive(Debug, Serialize)]
352pub struct AgentRefView {
353 profile: String,
354 exists: bool,
355 known_agent: bool,
356}
357
358#[derive(Debug, Serialize)]
360pub struct RepositoryView {
361 repository: String,
362 state: &'static str,
363 detail: Option<String>,
364 profile: Option<ProfileRefView>,
365 agents: BTreeMap<String, AgentRefView>,
366}
367
368#[derive(Debug, Serialize)]
370pub struct RepositoriesDocument {
371 schema: &'static str,
372 tool_version: &'static str,
373 repositories: Vec<RepositoryView>,
374}
375
376pub fn repository_view(entry: &Entry) -> RepositoryView {
378 RepositoryView {
379 repository: entry.key.clone(),
380 state: entry.state.token(),
381 detail: entry.detail.clone(),
382 profile: entry.profile.as_ref().map(|profile| ProfileRefView {
383 name: profile.name.to_string(),
384 exists: profile.exists,
385 }),
386 agents: entry
387 .agents
388 .iter()
389 .map(|(id, agent)| {
390 (
391 id.as_str().to_owned(),
392 AgentRefView {
393 profile: agent.profile.to_string(),
394 exists: agent.exists,
395 known_agent: agent.known,
396 },
397 )
398 })
399 .collect(),
400 }
401}
402
403pub fn repositories(entries: &[Entry]) -> RepositoriesDocument {
405 RepositoriesDocument {
406 schema: "agent-profile/repositories/1",
407 tool_version: TOOL_VERSION,
408 repositories: entries.iter().map(repository_view).collect(),
409 }
410}
411
412#[derive(Debug, Serialize)]
414pub struct DoctorCounts {
415 pass: usize,
416 info: usize,
417 warn: usize,
418 fail: usize,
419 skip: usize,
420}
421
422#[derive(Debug, Serialize)]
424pub struct CheckView {
425 id: &'static str,
426 agent: Option<&'static str>,
427 subject: Option<String>,
428 status: &'static str,
429 message: String,
430 data: serde_json::Value,
431}
432
433#[derive(Debug, Serialize)]
435pub struct DoctorDocument {
436 schema: &'static str,
437 tool_version: &'static str,
438 agent: Option<String>,
439 profile: Option<String>,
440 repository: Option<String>,
441 result: &'static str,
442 counts: DoctorCounts,
443 checks: Vec<CheckView>,
444}
445
446pub fn doctor(report: &Report) -> DoctorDocument {
447 DoctorDocument {
448 schema: "agent-profile/doctor/1",
449 tool_version: TOOL_VERSION,
450 agent: report.agent.as_ref().map(|agent| agent.as_str().to_owned()),
451 profile: report.profile.as_ref().map(ToString::to_string),
452 repository: report.repository.as_ref().map(|path| path.display().to_string()),
453 result: if report.failed() { "fail" } else { "ok" },
454 counts: DoctorCounts {
455 pass: report.count(Status::Pass),
456 info: report.count(Status::Info),
457 warn: report.count(Status::Warn),
458 fail: report.count(Status::Fail),
459 skip: report.count(Status::Skip),
460 },
461 checks: report
462 .checks
463 .iter()
464 .map(|check| CheckView {
465 id: check.id.token(),
466 agent: check.agent,
467 subject: check.subject.clone(),
468 status: check.status.token(),
469 message: check.message.clone(),
470 data: check.data.clone(),
471 })
472 .collect(),
473 }
474}
475
476#[cfg(all(test, debug_assertions))]
477mod tests {
478 use super::*;
479 use crate::config::AppRoot;
480 use crate::name::{Platform, ProfileName};
481 use serde_json::Value;
482
483 fn fake() -> AgentId {
484 AgentId::parse("fake").unwrap()
485 }
486
487 fn profile(name: &str) -> ProfileName {
488 ProfileName::parse(name, Platform::host()).unwrap()
489 }
490
491 fn parsed(document: &impl Serialize) -> Value {
492 serde_json::from_str(&render(document)).unwrap()
493 }
494
495 fn keys(value: &Value) -> Vec<&str> {
496 value.as_object().unwrap().keys().map(String::as_str).collect()
497 }
498
499 #[test]
500 fn resolve_document_carries_every_member_with_null_for_absent() {
501 let resolution = Resolution {
502 agent: fake(),
503 profile: None,
504 source: ResolutionSource::None,
505 repository: None,
506 };
507 let text = render(&resolve(&resolution));
508 let value: Value = serde_json::from_str(&text).unwrap();
509 assert_eq!(value["schema"], "agent-profile/resolve/1");
510 assert_eq!(value["tool_version"], env!("CARGO_PKG_VERSION"));
511 assert_eq!(value["agent"], "fake");
512 assert_eq!(value["profile"], Value::Null);
513 assert_eq!(value["source"], "none");
514 assert_eq!(value["repository"], Value::Null);
515 assert_eq!(value.as_object().unwrap().len(), 6);
516 assert!(
517 text.starts_with("{\n \"schema\": \"agent-profile/resolve/1\",\n \"tool_version\": ")
518 );
519 assert!(text.ends_with("}\n"));
520 }
521
522 #[test]
523 fn source_tokens_are_snake_case_and_closed() {
524 assert_eq!(source_token(ResolutionSource::Explicit), "explicit");
525 assert_eq!(source_token(ResolutionSource::RepositoryAgent), "repository_agent_mapping");
526 assert_eq!(source_token(ResolutionSource::RepositoryDefault), "repository_mapping");
527 assert_eq!(source_token(ResolutionSource::GlobalDefault), "global_default");
528 assert_eq!(source_token(ResolutionSource::None), "none");
529 }
530
531 #[test]
532 fn state_tokens_are_snake_case_and_closed() {
533 assert_eq!(state_token(CapabilityState::Supported), "supported");
534 assert_eq!(state_token(CapabilityState::NotSupported), "not_supported");
535 assert_eq!(state_token(CapabilityState::NotGuaranteed), "not_guaranteed");
536 assert_eq!(state_token(CapabilityState::Conditional), "conditional");
537 assert_eq!(state_token(CapabilityState::Unknown), "unknown");
538 assert_eq!(capability_token(Capability::ConfigIsolation), "config");
539 assert_eq!(capability_token(Capability::CredentialIsolation), "credentials");
540 assert_eq!(capability_token(Capability::StateIsolation), "state");
541 assert_eq!(support_token(SupportLevel::Proven), "proven");
542 assert_eq!(support_token(SupportLevel::Experimental), "experimental");
543 }
544
545 #[test]
546 fn status_document_outside_a_repository_is_all_null_and_empty() {
547 let rows = [(
548 Resolution {
549 agent: fake(),
550 profile: None,
551 source: ResolutionSource::None,
552 repository: None,
553 },
554 None,
555 )];
556 let value = parsed(&status(None, &Discovery::NotInRepository, &Config::default(), &rows));
557 assert_eq!(value["agent"], Value::Null);
558 assert_eq!(value["repository"], Value::Null);
559 assert_eq!(value["mapping"], Value::Null);
560 assert_eq!(value["default_profile"], Value::Null);
561 assert_eq!(value["agents"][0]["presence"], Value::Null);
562 assert_eq!(value["inapplicable_mappings"], serde_json::json!([]));
563 assert_eq!(
564 keys(&value),
565 [
566 "agent",
567 "agents",
568 "default_profile",
569 "inapplicable_mappings",
570 "mapping",
571 "repository",
572 "schema",
573 "tool_version"
574 ]
575 );
576 }
577
578 #[test]
579 fn status_document_inside_a_repository_carries_the_mapping_presence_and_ancestors() {
580 let temp = tempfile::tempdir().unwrap();
581 let outer = temp.path().join("outer");
582 let inner = outer.join("inner");
583 let root = AppRoot::from_path(temp.path().join("home"));
584 std::fs::create_dir_all(root.config_path().parent().unwrap()).unwrap();
585 let text = format!(
586 "default_profile = \"work\"\n\
587 [repositories.'{outer}']\nprofile = \"outer\"\n\
588 [repositories.'{inner}']\nprofile = \"inner\"\nagents = {{ fake = \"mine\" }}\n",
589 outer = outer.display(),
590 inner = inner.display()
591 );
592 std::fs::write(root.config_path(), text).unwrap();
593 let config = Config::load(&root).unwrap();
594 let rows = [(
595 Resolution {
596 agent: fake(),
597 profile: Some(profile("mine")),
598 source: ResolutionSource::RepositoryAgent,
599 repository: Some(inner.clone()),
600 },
601 Some(Presence::Conflict { existing: "Mine".into() }),
602 )];
603 let agent = fake();
604 let value =
605 parsed(&status(Some(&agent), &Discovery::Repository(inner.clone()), &config, &rows));
606 assert_eq!(value["agent"], "fake");
607 assert_eq!(value["repository"], inner.display().to_string());
608 assert_eq!(value["mapping"]["profile"], "inner");
609 assert_eq!(value["mapping"]["agents"], serde_json::json!({"fake": "mine"}));
610 assert_eq!(value["default_profile"], "work");
611 assert_eq!(value["agents"][0]["source"], "repository_agent_mapping");
612 assert_eq!(
613 value["agents"][0]["presence"],
614 serde_json::json!({"state": "conflict", "existing": "Mine"})
615 );
616 assert_eq!(
617 value["inapplicable_mappings"],
618 serde_json::json!([outer.display().to_string()])
619 );
620 }
621
622 #[test]
623 fn agents_document_renders_each_executable_state_and_the_full_matrix() {
624 use crate::adapter::{self, Capability};
625 use crate::exe::{ExecutableStatus, Found, Origin};
626 use std::path::PathBuf;
627
628 let metadata = adapter::lookup("fake").unwrap().metadata();
629 let entries = [
630 (
631 metadata,
632 ExecutableStatus::Found(Found {
633 path: PathBuf::from("/bin/fake"),
634 origin: Origin::Configured,
635 }),
636 ),
637 (
638 metadata,
639 ExecutableStatus::Found(Found {
640 path: PathBuf::from("/usr/bin/fake"),
641 origin: Origin::Path,
642 }),
643 ),
644 (metadata, ExecutableStatus::NotOnPath),
645 (metadata, ExecutableStatus::ConfiguredMissing(PathBuf::from("/gone"))),
646 (metadata, ExecutableStatus::Unsupported(PathBuf::from("/shim.cmd"))),
647 ];
648 let value = parsed(&agents(&entries));
649 assert_eq!(value["schema"], "agent-profile/agents/1");
650 let list = value["agents"].as_array().unwrap();
651 assert_eq!(list.len(), 5);
652 let expected = [
653 ("found", Some("/bin/fake"), Some("configured")),
654 ("found", Some("/usr/bin/fake"), Some("path")),
655 ("not_on_path", None, None),
656 ("configured_missing", Some("/gone"), None),
657 ("unsupported", Some("/shim.cmd"), None),
658 ];
659 for (element, (state, path, origin)) in list.iter().zip(expected) {
660 let executable = &element["executable"];
661 assert_eq!(executable["state"], state);
662 assert_eq!(executable["path"], serde_json::json!(path));
663 assert_eq!(executable["origin"], serde_json::json!(origin));
664 assert_eq!(keys(executable), ["origin", "path", "state"]);
665 let capabilities = element["capabilities"].as_array().unwrap();
666 assert_eq!(capabilities.len(), 3);
667 for (entry, capability) in capabilities.iter().zip(Capability::ALL) {
668 assert_eq!(entry["capability"], capability_token(capability));
669 }
670 let order: Vec<&str> =
671 capabilities.iter().map(|c| c["capability"].as_str().unwrap()).collect();
672 assert_eq!(order, ["config", "credentials", "state"]);
673 assert!(["proven", "experimental"].contains(&element["support"].as_str().unwrap()));
674 assert_eq!(element["mechanism"]["id"], metadata.evidence.mechanism_id);
675 assert_eq!(
676 keys(&element["evidence"]),
677 ["notes", "source_url", "upstream_version", "verified_at"]
678 );
679 assert_eq!(
680 keys(element),
681 ["agent", "capabilities", "evidence", "executable", "mechanism", "support"]
682 );
683 }
684 }
685
686 #[test]
687 fn profiles_document_filters_by_agent_without_changing_rows() {
688 use crate::lifecycle::Listing;
689 let listing = Listing {
690 rows: vec![
691 (profile("personal"), vec!["claude".to_owned()]),
692 (profile("work"), vec!["claude".to_owned(), "codex".to_owned()]),
693 ],
694 ignored: 2,
695 };
696 let all = parsed(&profiles(&listing, None));
697 assert_eq!(all["schema"], "agent-profile/profiles/1");
698 assert_eq!(all["agent"], Value::Null);
699 assert_eq!(all["ignored_entries"], 2);
700 assert_eq!(
701 all["profiles"],
702 serde_json::json!([
703 {"name": "personal", "agents": ["claude"]},
704 {"name": "work", "agents": ["claude", "codex"]}
705 ])
706 );
707 let codex = AgentId::parse("codex").unwrap();
708 let some = parsed(&profiles(&listing, Some(&codex)));
709 assert_eq!(some["agent"], "codex");
710 assert_eq!(
711 some["profiles"],
712 serde_json::json!([{"name": "work", "agents": ["claude", "codex"]}])
713 );
714 }
715
716 fn record(
717 id: crate::doctor::CheckId,
718 agent: Option<&'static str>,
719 status: crate::doctor::Status,
720 message: &str,
721 ) -> crate::doctor::Check {
722 crate::doctor::Check {
723 id,
724 agent,
725 subject: None,
726 status,
727 message: message.to_owned(),
728 data: Value::Null,
729 }
730 }
731
732 fn report(checks: Vec<crate::doctor::Check>) -> crate::doctor::Report {
733 crate::doctor::Report { agent: None, profile: None, repository: None, checks }
734 }
735
736 #[test]
737 fn doctor_document_has_the_envelope_and_all_counts() {
738 use crate::doctor::{CheckId, Status};
739 let mut report = report(vec![record(CheckId::Platform, None, Status::Info, "linux")]);
740 report.agent = Some(fake());
741 report.profile = Some(profile("work"));
742 report.repository = Some(std::path::PathBuf::from("/src/acme"));
743 let value = parsed(&doctor(&report));
744 let text = render(&doctor(&report));
746 let order = [
747 "schema",
748 "tool_version",
749 "agent",
750 "profile",
751 "repository",
752 "result",
753 "counts",
754 "checks",
755 ];
756 let positions: Vec<usize> =
757 order.iter().map(|key| text.find(&format!("\n \"{key}\":")).unwrap()).collect();
758 assert!(positions.windows(2).all(|pair| pair[0] < pair[1]), "{text}");
759 assert_eq!(keys(&value).len(), order.len());
760 assert_eq!(value["schema"], "agent-profile/doctor/1");
761 assert_eq!(value["agent"], "fake");
762 assert_eq!(value["profile"], "work");
763 assert_eq!(value["repository"], "/src/acme");
764 let counts = &text[text.find("\"counts\"").unwrap()..text.find("\"checks\"").unwrap()];
765 let count_order: Vec<usize> = ["pass", "info", "warn", "fail", "skip"]
766 .iter()
767 .map(|k| counts.find(&format!("\"{k}\"")).unwrap())
768 .collect();
769 assert!(count_order.windows(2).all(|pair| pair[0] < pair[1]), "{counts}");
770 assert_eq!(keys(&value["counts"]).len(), 5);
771 assert_eq!(value["counts"]["info"], 1);
772 assert_eq!(value["counts"]["pass"], 0);
773 let check = &value["checks"][0];
774 assert_eq!(keys(check), ["agent", "data", "id", "message", "status", "subject"]);
775 let rendered = &text[text.find("\"checks\"").unwrap()..];
776 let check_order: Vec<usize> = ["id", "agent", "subject", "status", "message", "data"]
777 .iter()
778 .map(|key| rendered.find(&format!("\"{key}\":")).unwrap())
779 .collect();
780 assert!(check_order.windows(2).all(|pair| pair[0] < pair[1]), "{rendered}");
781 assert_eq!(check["id"], "platform");
782 assert_eq!(check["status"], "info");
783 assert!(check["agent"].is_null() && check["subject"].is_null() && check["data"].is_null());
784
785 let bare = parsed(&doctor(&self::report(Vec::new())));
786 assert!(
787 bare["agent"].is_null() && bare["profile"].is_null() && bare["repository"].is_null()
788 );
789 }
790
791 #[test]
792 fn doctor_document_result_tracks_failed() {
793 use crate::doctor::{CheckId, Status};
794 let failing = report(vec![record(CheckId::Config, None, Status::Fail, "bad")]);
795 assert_eq!(parsed(&doctor(&failing))["result"], "fail");
796 let fine = report(vec![record(CheckId::Config, None, Status::Warn, "meh")]);
797 assert_eq!(parsed(&doctor(&fine))["result"], "ok");
798 }
799}