Skip to main content

agent_profile/
json.rs

1// Copyright (c) 2026 Costas Kirgoussios
2//
3// This software is licensed under the PolyForm Noncommercial License 1.0.0.
4// You may obtain a copy of the License at:
5// https://polyformproject.org/licenses/noncommercial/1.0.0/
6
7//! The serialisable view layer behind `--json` (SP5b design §3.1 to §3.7).
8//!
9//! Every document is a struct whose field order is its JSON member order; an absent value is `null`,
10//! never a missing member, so a consumer sees the same members on every run.
11
12use 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
26/// The `tool_version` member of every document.
27pub const TOOL_VERSION: &str = env!("CARGO_PKG_VERSION");
28
29/// Two-space pretty print plus a trailing newline.
30pub 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
36/// The closed JSON token for a resolution source (the text label is for humans).
37pub 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/// The `resolve` document.
48#[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/// A profile's presence: the token, plus the twin's spelling when the state is `conflict`.
70#[derive(Debug, Serialize)]
71pub struct PresenceView {
72    state: &'static str,
73    existing: Option<String>,
74}
75
76/// One agent's resolution within the `status` document.
77#[derive(Debug, Serialize)]
78pub struct AgentResolutionView {
79    agent: String,
80    profile: Option<String>,
81    source: &'static str,
82    presence: Option<PresenceView>,
83}
84
85/// The mapping entry that applies to the current repository.
86#[derive(Debug, Serialize)]
87pub struct MappingView {
88    profile: Option<String>,
89    agents: BTreeMap<String, String>,
90}
91
92/// The `status` document.
93#[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
156/// The closed JSON token for a capability state.
157pub 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
167/// The closed JSON token for a capability.
168pub 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
176/// The closed JSON token for a support level.
177pub fn support_token(support: SupportLevel) -> &'static str {
178    match support {
179        SupportLevel::Proven => "proven",
180        SupportLevel::Experimental => "experimental",
181    }
182}
183
184/// An agent's executable evidence.
185#[derive(Debug, Serialize)]
186pub struct ExecutableView {
187    state: &'static str,
188    path: Option<String>,
189    origin: Option<&'static str>,
190}
191
192/// How the agent is pointed at the profile.
193#[derive(Debug, Serialize)]
194pub struct MechanismView {
195    id: &'static str,
196    description: String,
197}
198
199/// The evidence the adapter's claims rest on.
200#[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/// One capability claim.
209#[derive(Debug, Serialize)]
210pub struct CapabilityView {
211    capability: &'static str,
212    state: &'static str,
213    basis: &'static str,
214}
215
216/// One registry entry of the `agents` document.
217#[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/// The `agents` document.
228#[derive(Debug, Serialize)]
229pub struct AgentsDocument {
230    schema: &'static str,
231    tool_version: &'static str,
232    agents: Vec<AgentView>,
233}
234
235/// The JSON view of one executable status.
236pub 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
262/// The JSON view of one registry entry and its executable status.
263pub 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
297/// Builds the `agents` document from registry metadata and each agent's executable status.
298pub 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/// One profile and the agents materialized for it.
304#[derive(Debug, Serialize)]
305pub struct ProfileView {
306    name: String,
307    agents: Vec<String>,
308}
309
310/// The `profiles` (and `list`) document.
311#[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    /// Whether no profile row survived the agent filter.
322    pub fn is_empty(&self) -> bool {
323        self.profiles.is_empty()
324    }
325}
326
327/// Builds the `profiles` document; with an agent, only rows that materialize it, each row unchanged.
328pub 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/// A profile a repository entry names, and whether its directory exists.
344#[derive(Debug, Serialize)]
345pub struct ProfileRefView {
346    name: String,
347    exists: bool,
348}
349
350/// An agent field of a repository entry.
351#[derive(Debug, Serialize)]
352pub struct AgentRefView {
353    profile: String,
354    exists: bool,
355    known_agent: bool,
356}
357
358/// One stored repository mapping with its classification.
359#[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/// The `repositories` document.
369#[derive(Debug, Serialize)]
370pub struct RepositoriesDocument {
371    schema: &'static str,
372    tool_version: &'static str,
373    repositories: Vec<RepositoryView>,
374}
375
376/// The JSON view of one classified repository entry.
377pub 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
403/// Builds the `repositories` document; the entries arrive sorted and keep their order.
404pub 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/// The per-status counts of a `doctor` document; every key is always present.
413#[derive(Debug, Serialize)]
414pub struct DoctorCounts {
415    pass: usize,
416    info: usize,
417    warn: usize,
418    fail: usize,
419    skip: usize,
420}
421
422/// One record of a `doctor` document.
423#[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/// The `doctor` document (SP5c design §6).
434#[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        // `Value` objects iterate sorted, so the member order is read from the rendered text.
745        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}