Skip to main content

agent_profile/
output.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//! Human output: the dry-run and `--verbose` report (spec §26, SP1 design §7.4, SP2 design §7.3), the
8//! repository command reports (SP3 design §7.5) and redaction (spec §22). JSON output (spec §32) is built in
9//! `json` (SP5b).
10
11use std::ffi::OsStr;
12use std::path::{Path, PathBuf};
13
14use crate::adapter::{
15    AdapterMetadata, Capability, CapabilityState, PathKind, PlannedLaunch, SupportLevel,
16};
17use crate::config::{Config, LinkOutcome, UnlinkOutcome};
18use crate::doctor::{Check, Report, Status};
19use crate::exe::Origin;
20use crate::lifecycle::{CreateOutcome, DeleteOutcome, Listing};
21use crate::name::{AgentId, ProfileName};
22use crate::repo::Discovery;
23use crate::resolve::Resolution;
24
25/// Substrings that mark an override variable as secret-bearing, whatever the adapter declared.
26const SENSITIVE_NAME_PARTS: [&str; 6] =
27    ["TOKEN", "SECRET", "KEY", "PASSWORD", "CREDENTIAL", "AUTH"];
28
29const LABEL_WIDTH: usize = 14;
30
31/// Which report is rendered.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum ReportMode {
34    /// Before anything is created: marks and lists what a launch would create.
35    DryRun,
36    /// After initialization: nothing is left to create, so there are no markers and no `creates:` line.
37    Verbose,
38}
39
40/// How a support level is spelled in the report and in the launch hedge (SP4 design §7.1).
41pub fn support_label(support: SupportLevel) -> &'static str {
42    match support {
43        SupportLevel::Proven => "proven",
44        SupportLevel::Experimental => "experimental",
45    }
46}
47
48/// How a capability is spelled. One vocabulary covers the report and the hedge (SP4 design §7.2).
49pub fn capability_label(capability: Capability) -> &'static str {
50    match capability {
51        Capability::ConfigIsolation => "config",
52        Capability::CredentialIsolation => "credentials",
53        Capability::StateIsolation => "state",
54    }
55}
56
57/// How a capability state is spelled: lower case, with spaces (SP4 design §7.1).
58pub fn state_label(state: CapabilityState) -> &'static str {
59    match state {
60        CapabilityState::Supported => "supported",
61        CapabilityState::NotSupported => "not supported",
62        CapabilityState::NotGuaranteed => "not guaranteed",
63        CapabilityState::Conditional => "conditional",
64        CapabilityState::Unknown => "unknown",
65    }
66}
67
68/// The one-line stderr hedge for an adapter that is not `Proven`, or `None` when it is (SP4 design §7.2).
69///
70/// It lists every capability whose state is not `Supported`, in `Capability::ALL` order, using the
71/// report's own vocabulary. `NotGuaranteed` counts: leaving it out would have hidden the state the design
72/// predicts for an adapter whose documented mechanism is ignored by part of the agent.
73pub fn support_hedge(metadata: &AdapterMetadata) -> Option<String> {
74    if metadata.support == SupportLevel::Proven {
75        return None;
76    }
77    let support = support_label(metadata.support);
78    let weak: Vec<String> = Capability::ALL
79        .into_iter()
80        .filter_map(|capability| {
81            match metadata.capabilities.iter().find(|claim| claim.capability == capability) {
82                None => Some(format!("{} not declared", capability_label(capability))),
83                Some(claim) if claim.state != CapabilityState::Supported => {
84                    Some(format!("{} {}", capability_label(capability), state_label(claim.state)))
85                }
86                Some(_) => None,
87            }
88        })
89        .collect();
90    let detail = if weak.is_empty() { String::new() } else { format!(": {}", weak.join(", ")) };
91    Some(format!("{} is {support}{detail}. Run with --dry-run for detail.", metadata.id))
92}
93
94/// The report lines, without trailing newlines.
95pub fn report_lines(
96    planned: &PlannedLaunch,
97    resolution: &Resolution,
98    mode: ReportMode,
99    metadata: &AdapterMetadata,
100) -> Vec<String> {
101    let mut lines = Vec::new();
102    let line = |label: &str, value: String| format!("{:<LABEL_WIDTH$}{value}", format!("{label}:"));
103    let continuation = |value: String| format!("{:LABEL_WIDTH$}{value}", "");
104    lines.push(line("agent", resolution.agent.to_string()));
105    lines.push(line("profile", format!("{} ({})", planned.profile, resolution.source.label())));
106    lines.push(line(
107        "executable",
108        format!(
109            "{} ({})",
110            planned.plan.executable.display(),
111            origin_word(planned.executable_origin)
112        ),
113    ));
114    let repository = match &resolution.repository {
115        Some(path) => path.display().to_string(),
116        None => "none".to_owned(),
117    };
118    lines.push(line("repository", repository));
119    lines.push(line("mechanism", planned.mechanism.clone()));
120    lines.push(line("support", support_label(metadata.support).to_owned()));
121    lines.extend(capability_lines(metadata));
122    if planned.plan.env.is_empty() {
123        lines.push(line("environment", "none".to_owned()));
124    }
125    let missing: Vec<_> = planned.paths.iter().filter(|entry| !entry.existed).collect();
126    for (index, (key, value)) in planned.plan.env.iter().enumerate() {
127        let shown = if is_sensitive(key, &planned.sensitive_env) {
128            "<redacted>".to_owned()
129        } else {
130            let mut shown = value.to_string_lossy().into_owned();
131            let will_create = missing.iter().any(|entry| {
132                entry.kind == PathKind::Dir && entry.path.as_os_str() == value.as_os_str()
133            });
134            if mode == ReportMode::DryRun && will_create {
135                shown.push_str(" (would be created)");
136            }
137            shown
138        };
139        let entry = format!("{}={shown}", key.to_string_lossy());
140        lines.push(if index == 0 { line("environment", entry) } else { continuation(entry) });
141    }
142    if mode == ReportMode::DryRun {
143        if missing.is_empty() {
144            lines.push(line("creates", "none".to_owned()));
145        }
146        for (index, entry) in missing.iter().enumerate() {
147            let value = format!("{} (would be created)", entry.path.display());
148            lines.push(if index == 0 { line("creates", value) } else { continuation(value) });
149        }
150    }
151    let args = render_args(&planned.plan.args);
152    lines.push(line("arguments", format!("[{}]", args.join(", "))));
153    for note in &planned.notes {
154        lines.push(line("note", note.clone()));
155    }
156    lines
157}
158
159/// Where a launch's executable came from, spelled as the report does.
160fn origin_word(origin: Origin) -> &'static str {
161    match origin {
162        Origin::Configured => "configured",
163        Origin::Path => "PATH",
164    }
165}
166
167/// The capability-matrix block of the report: every capability in `Capability::ALL`, in that fixed order,
168/// each followed by its basis. The matrix is unconditional: SP4 design §6.1 rests on a support level never
169/// being shown without it, so every block that shows a support level (`report_lines`, `agents_lines`)
170/// renders this whole, without a leading or a trailing line of its own.
171pub fn capability_lines(metadata: &AdapterMetadata) -> Vec<String> {
172    let line = |label: &str, value: String| format!("{:<LABEL_WIDTH$}{value}", format!("{label}:"));
173    let continuation = |value: String| format!("{:LABEL_WIDTH$}{value}", "");
174    let mut lines = Vec::new();
175    for (index, capability) in Capability::ALL.into_iter().enumerate() {
176        let claim = metadata.capabilities.iter().find(|claim| claim.capability == capability);
177        let label = capability_label(capability);
178        let value = match claim {
179            Some(claim) => format!("{label}: {}", state_label(claim.state)),
180            // Not every caller is a registry adapter: `metadata_invariants` requires one claim per
181            // capability, but `report_lines` is reachable from a fixture that declares none. "not declared"
182            // is visibly different from "unknown", and neither panics.
183            None => format!("{label}: not declared"),
184        };
185        lines.push(if index == 0 { line("isolation", value) } else { continuation(value) });
186        if let Some(claim) = claim {
187            lines.push(continuation(format!("  {}", claim.basis)));
188        }
189    }
190    lines
191}
192
193/// `label:` padded to the report column, then `value`.
194pub(crate) fn labeled(label: &str, value: impl std::fmt::Display) -> String {
195    format!("{:<LABEL_WIDTH$}{value}", format!("{label}:"))
196}
197
198/// A `note:` line.
199pub fn note(text: &str) -> String {
200    labeled("note", text)
201}
202
203fn or_none(value: Option<impl std::fmt::Display>) -> String {
204    value.map_or_else(|| "none".to_owned(), |value| value.to_string())
205}
206
207/// `<agent> resolve` (SP3 design §7.5).
208pub fn resolve_lines(resolution: &Resolution) -> Vec<String> {
209    vec![
210        labeled("agent", &resolution.agent),
211        labeled("profile", or_none(resolution.profile.as_ref())),
212        labeled("source", resolution.source.label()),
213        labeled("repository", or_none(resolution.repository.as_deref().map(Path::display))),
214    ]
215}
216
217/// `status` and `<agent> status`: `agents` holds one resolution per agent line and, for `<agent> status`,
218/// its `presence:` value (SP3 design §7.5).
219pub fn status_lines(
220    discovery: &Discovery,
221    config: &Config,
222    agents: &[(Resolution, Option<String>)],
223) -> Vec<String> {
224    let (repository, mapping) = match discovery {
225        Discovery::Repository(root) => (root.display().to_string(), config.mapping(root)),
226        Discovery::NotInRepository => ("not in a repository".to_owned(), None),
227    };
228    let mut lines = vec![labeled("repository", repository)];
229    lines.push(labeled("mapping", or_none(mapping.and_then(|mapping| mapping.profile.as_ref()))));
230    let pairs = mapping
231        .map(|mapping| {
232            mapping.agents.iter().map(|(id, profile)| format!("{id}={profile}")).collect::<Vec<_>>()
233        })
234        .filter(|pairs| !pairs.is_empty())
235        .map(|pairs| pairs.join(", "));
236    lines.push(labeled("agents", or_none(pairs)));
237    lines.push(labeled("default", or_none(config.default_profile())));
238    for (resolution, presence) in agents {
239        let value = match &resolution.profile {
240            Some(profile) => format!("{profile} ({})", resolution.source.label()),
241            None => "none".to_owned(),
242        };
243        lines.push(labeled(resolution.agent.as_str(), value));
244        if let Some(presence) = presence {
245            lines.push(labeled("presence", presence));
246        }
247    }
248    if let Discovery::Repository(root) = discovery {
249        for (key, _) in config.mappings().filter(|(key, _)| is_proper_ancestor(key, root)) {
250            lines.push(note(&format!(
251                "{} has a mapping that does not apply to this repository",
252                key.display()
253            )));
254        }
255    }
256    lines
257}
258
259/// A path to paste into a command: in double quotes when it contains whitespace, which POSIX shells, PowerShell
260/// and `cmd` all read as one argument.
261pub(crate) fn shell_word(path: &Path) -> String {
262    let shown = path.display().to_string();
263    if shown.contains(char::is_whitespace) { format!("\"{shown}\"") } else { shown }
264}
265
266pub(crate) use crate::repo::is_proper_ancestor;
267
268/// `link` (SP3 design §7.5).
269pub fn link_line(
270    outcome: &LinkOutcome,
271    agent: Option<&AgentId>,
272    repository: &Path,
273    profile: &ProfileName,
274) -> String {
275    let agent = agent.map(|agent| format!("{agent}: ")).unwrap_or_default();
276    let repository = repository.display();
277    match outcome {
278        LinkOutcome::Linked => format!("linked {agent}{repository} -> {profile}"),
279        LinkOutcome::Changed { old } => format!("changed {agent}{repository}: {old} -> {profile}"),
280        LinkOutcome::AlreadyLinked => format!("already linked {agent}{repository} -> {profile}"),
281    }
282}
283
284/// `unlink` (SP3 design §7.5). `config` is the configuration read before the command, `keys` the candidates.
285pub fn unlink_lines(
286    outcome: &UnlinkOutcome,
287    agent: Option<&AgentId>,
288    config: &Config,
289    keys: &[PathBuf],
290) -> Vec<String> {
291    let prefix = agent.map(|agent| format!("{agent}: ")).unwrap_or_default();
292    let shown = match outcome {
293        UnlinkOutcome::Unlinked { root, old } => {
294            let mut lines = vec![format!("unlinked {prefix}{} (was {old})", root.display())];
295            // The repository profile is gone but agent mappings under the same key are not: say so on the
296            // run that creates that state, not only on the next one.
297            if agent.is_none()
298                && let Some(mapping) = config.mapping(root)
299            {
300                lines.extend(agent_mappings_remain(mapping));
301            }
302            return lines;
303        }
304        UnlinkOutcome::NothingToRemove { shown } => shown,
305    };
306    let mut lines = vec![format!("no mapping to remove for {prefix}{}", shown.display())];
307    for (key, _) in config.mappings().filter(|(key, _)| is_proper_ancestor(key, shown)) {
308        lines.push(note(&format!(
309            "{} has a mapping; remove it with agent-profile unlink --repo {}",
310            key.display(),
311            shell_word(key)
312        )));
313    }
314    if agent.is_none()
315        && let Some(mapping) = keys.iter().find_map(|key| config.mapping(key))
316        && mapping.profile.is_none()
317    {
318        lines.extend(agent_mappings_remain(mapping));
319    }
320    lines
321}
322
323/// The note that agent mappings survive a repository-level `unlink`, or nothing when there are none.
324fn agent_mappings_remain(mapping: &crate::config::Mapping) -> Option<String> {
325    if mapping.agents.is_empty() {
326        return None;
327    }
328    let pairs: Vec<String> =
329        mapping.agents.iter().map(|(id, profile)| format!("{id}={profile}")).collect();
330    Some(note(&format!(
331        "agent mappings remain: {}; remove them with agent-profile <agent> unlink",
332        pairs.join(", ")
333    )))
334}
335
336/// `create` (SP5a design §3).
337pub fn create_line(
338    outcome: &CreateOutcome,
339    agent: &str,
340    profile: &ProfileName,
341    path: &Path,
342) -> String {
343    let path = path.display();
344    match outcome {
345        CreateOutcome::Created => format!("created profile '{profile}' for {agent}: {path}"),
346        CreateOutcome::AlreadyExists => {
347            format!("profile '{profile}' for {agent} already exists: {path}")
348        }
349    }
350}
351
352/// `delete` (SP5a design §3): the stdout line of each outcome. A profile that vanished before the locked
353/// delete ran reports the same does-not-exist line as an advisory miss, because that is the state the
354/// user asked for.
355pub fn delete_line(outcome: &DeleteOutcome, agent: &str, profile: &ProfileName) -> String {
356    match outcome {
357        DeleteOutcome::Deleted { path, .. } => {
358            format!("deleted profile '{profile}' for {agent}: {}", path.display())
359        }
360        DeleteOutcome::Vanished { .. } => missing_profile_line(agent, profile),
361    }
362}
363
364/// The stdout line for a deleting command whose profile is not there.
365pub fn missing_profile_line(agent: &str, profile: &ProfileName) -> String {
366    format!("profile '{profile}' for {agent} does not exist; nothing to delete")
367}
368
369/// `profiles` (SP5a design §3): one line per row, the profile name padded to the longest name so the
370/// agent columns align; an empty listing is the single line `no profiles`. A profile with no agent
371/// materialized is shown as `(none materialized)` rather than an empty column.
372pub fn profiles_lines(listing: &Listing) -> Vec<String> {
373    if listing.rows.is_empty() {
374        return vec!["no profiles".to_owned()];
375    }
376    let width = listing.rows.iter().map(|(name, _)| name.as_str().len()).max().unwrap_or(0);
377    listing
378        .rows
379        .iter()
380        .map(|(name, agents)| {
381            let agents = if agents.is_empty() {
382                "(none materialized)".to_owned()
383            } else {
384                agents.join(", ")
385            };
386            format!("{:<width$}  {}", name.as_str(), agents)
387        })
388        .collect()
389}
390
391/// `list` (SP5a design §3): only the names of the rows whose agents contain `agent`, one per line. The
392/// result may be empty; the CLI layer decides what that means for stderr.
393pub fn list_lines(listing: &Listing, agent: &str) -> Vec<String> {
394    listing
395        .rows
396        .iter()
397        .filter(|(_, agents)| agents.iter().any(|id| id == agent))
398        .map(|(name, _)| name.as_str().to_owned())
399        .collect()
400}
401
402pub use crate::exe::ExecutableStatus;
403
404/// `repositories` (SP5b design §4): a block per stored mapping, a blank line between blocks. The key is
405/// printed as stored.
406pub fn repositories_lines(entries: &[crate::repositories::Entry]) -> Vec<String> {
407    use crate::repositories::State;
408    if entries.is_empty() {
409        return vec!["no repository mappings".to_owned()];
410    }
411    let mut lines = Vec::new();
412    for (index, entry) in entries.iter().enumerate() {
413        if index > 0 {
414            lines.push(String::new());
415        }
416        lines.push(labeled("repository", &entry.key));
417        let state = match (&entry.state, &entry.detail) {
418            (State::Moved, Some(detail)) => format!("{} (now {detail})", entry.state.token()),
419            (State::Unreadable, Some(detail)) => format!("{} ({detail})", entry.state.token()),
420            _ => entry.state.token().to_owned(),
421        };
422        lines.push(labeled("state", state));
423        lines.push(labeled(
424            "profile",
425            entry.profile.as_ref().map_or_else(
426                || "none".to_owned(),
427                |profile| {
428                    let missing = if profile.exists { "" } else { " (no profile directory)" };
429                    format!("{}{missing}", profile.name)
430                },
431            ),
432        ));
433        let agents = if entry.agents.is_empty() {
434            "none".to_owned()
435        } else {
436            entry
437                .agents
438                .iter()
439                .map(|(id, agent)| {
440                    let missing = if agent.exists { "" } else { " (no profile directory)" };
441                    let unknown = if agent.known { "" } else { " (unknown agent)" };
442                    format!("{id}={}{missing}{unknown}", agent.profile)
443                })
444                .collect::<Vec<_>>()
445                .join(", ")
446        };
447        lines.push(labeled("agents", agents));
448        if entry.profile.is_none() && entry.agents.is_empty() {
449            lines.push(note("maps nothing; remove it by editing config.toml"));
450        }
451    }
452    lines
453}
454
455/// `agents` (SP5a design §3): a padded block per entry — the agent, the executable status, the mechanism,
456/// the support level, then the evidence entry's measured upstream version with its verification date (a
457/// probe of whatever happens to be installed would say nothing about the matrix it pairs with), and the
458/// full capability matrix without which a support level is never shown (SP4 design §6.1). An empty line
459/// separates the entries; there is none after the last.
460pub fn agents_lines(entries: &[(&AdapterMetadata, ExecutableStatus)]) -> Vec<String> {
461    let mut lines = Vec::new();
462    for (index, (metadata, status)) in entries.iter().enumerate() {
463        if index > 0 {
464            lines.push(String::new());
465        }
466        lines.push(labeled("agent", metadata.id));
467        lines.push(labeled(
468            "executable",
469            match status {
470                ExecutableStatus::Found(found) => {
471                    format!("{} ({})", found.path.display(), origin_word(found.origin))
472                }
473                ExecutableStatus::NotOnPath => "not found on PATH".to_owned(),
474                ExecutableStatus::ConfiguredMissing(path) => {
475                    format!("{} (configured, missing)", path.display())
476                }
477                ExecutableStatus::Unsupported(path) => {
478                    format!("{} (unsupported: shell script or shim)", path.display())
479                }
480            },
481        ));
482        lines.push(labeled("mechanism", metadata.mechanism.to_string()));
483        lines.push(labeled("support", support_label(metadata.support)));
484        lines.push(labeled(
485            "version",
486            format!(
487                "{} (verified {})",
488                metadata.evidence.upstream_version, metadata.evidence.verified_at
489            ),
490        ));
491        lines.extend(capability_lines(metadata));
492    }
493    lines
494}
495
496fn is_sensitive(key: &OsStr, declared: &[std::ffi::OsString]) -> bool {
497    if declared.iter().any(|name| name == key) {
498        return true;
499    }
500    has_sensitive_part(&key.to_string_lossy())
501}
502
503fn has_sensitive_part(name: &str) -> bool {
504    let upper = name.to_ascii_uppercase();
505    SENSITIVE_NAME_PARTS.iter().any(|part| upper.contains(part))
506}
507
508/// How one opaque argument is shown (spec §26 "Sensitive values must be redacted", §36).
509enum Shown {
510    Verbatim,
511    /// The argument's value is replaced: `prefix<redacted>`.
512    Redacted(String),
513    /// The argument is a secret-named option without `=`: the next argument is its value.
514    HidesNext,
515}
516
517/// Renders the opaque arguments for the report. Only the report is redacted; the launched arguments never
518/// change. Redaction is a shallow name rule, never a parser: a `--option` whose name holds a sensitive part
519/// hides its value (`--api-key=<redacted>`, or the next argument); a `NAME=value` whose (possibly dotted) NAME
520/// holds one hides the value (`mcp_servers.gh.env.GITHUB_TOKEN=<redacted>`); a `Name: value` header whose name
521/// holds one hides the value (`Authorization: <redacted>`). Every argument is scanned, including after a `--`,
522/// and boolean-looking names get no exemption, because hiding too much only costs readability.
523fn render_args(args: &[std::ffi::OsString]) -> Vec<String> {
524    let mut rendered = Vec::with_capacity(args.len());
525    let mut hide_next = false;
526    for arg in args {
527        let shown = classify(&arg.to_string_lossy());
528        if hide_next {
529            // A hidden secret-named option still hides its own value, so a chain never leaks.
530            hide_next = matches!(shown, Shown::HidesNext);
531            rendered.push(format!("{:?}", "<redacted>"));
532            continue;
533        }
534        rendered.push(match shown {
535            Shown::Verbatim => render_arg(arg),
536            Shown::Redacted(prefix) => format!("{:?}", format!("{prefix}<redacted>")),
537            Shown::HidesNext => {
538                hide_next = true;
539                render_arg(arg)
540            }
541        });
542    }
543    rendered
544}
545
546fn classify(text: &str) -> Shown {
547    if let Some(option) = text.strip_prefix("--").filter(|option| !option.is_empty()) {
548        let (name, value) = match option.split_once('=') {
549            Some((name, value)) => (name, Some(value)),
550            None => (option, None),
551        };
552        if has_sensitive_part(name) {
553            return match value {
554                Some(_) => Shown::Redacted(format!("--{name}=")),
555                None => Shown::HidesNext,
556            };
557        }
558        return match value.and_then(sensitive_value_prefix) {
559            Some(prefix) => Shown::Redacted(format!("--{name}={prefix}")),
560            None => Shown::Verbatim,
561        };
562    }
563    match sensitive_value_prefix(text) {
564        Some(prefix) => Shown::Redacted(prefix),
565        None => Shown::Verbatim,
566    }
567}
568
569/// The shown prefix when `text` is a `NAME=value` assignment or a `Name: value` header whose name holds a
570/// sensitive part: `NAME=` or `Name: `.
571fn sensitive_value_prefix(text: &str) -> Option<String> {
572    if let Some((name, _)) = text.split_once('=')
573        && is_dotted_identifier(name)
574        && has_sensitive_part(name)
575    {
576        return Some(format!("{name}="));
577    }
578    let (name, _) = text.split_once(':')?;
579    let header = !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-');
580    (header && has_sensitive_part(name)).then(|| format!("{name}: "))
581}
582
583/// `A_1`, `mcp_servers.gh.env.GITHUB_TOKEN` or `mcp_servers.chrome-devtools.http_headers.X-Api-Key`: TOML
584/// bare keys (letters, digits, `_`, `-`) joined by dots, as Codex `-c` dotted paths are.
585fn is_dotted_identifier(name: &str) -> bool {
586    name.split('.').all(|segment| {
587        !segment.is_empty()
588            && segment.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
589    })
590}
591
592/// The `doctor` report (SP5c design §7): records grouped under `host`, `repositories` and one
593/// `agent <id>` heading per agent, then the summary line.
594pub fn doctor_lines(report: &Report) -> Vec<String> {
595    use crate::doctor::CheckId;
596
597    fn group(check: &Check) -> String {
598        if let Some(agent) = check.agent {
599            return format!("agent {agent}");
600        }
601        match check.id {
602            CheckId::Repository | CheckId::Mapping | CheckId::MappingAgent => {
603                "repositories".to_owned()
604            }
605            _ => "host".to_owned(),
606        }
607    }
608
609    let mut groups: Vec<(String, Vec<&Check>)> = Vec::new();
610    for check in &report.checks {
611        let name = group(check);
612        match groups.iter_mut().find(|(existing, _)| *existing == name) {
613            Some((_, members)) => members.push(check),
614            None => groups.push((name, vec![check])),
615        }
616    }
617
618    let mut lines = Vec::new();
619    for (name, members) in groups {
620        lines.push(name);
621        for check in members {
622            let prefix = check.agent.map(|agent| format!("[{agent}] ")).unwrap_or_default();
623            lines.push(format!(
624                "{:<4}  {}{}",
625                check.status.token().to_uppercase(),
626                prefix,
627                check.message
628            ));
629        }
630        lines.push(String::new());
631    }
632    let warnings = report.count(Status::Warn);
633    lines.push(if report.failed() {
634        format!("doctor: {} failed, {warnings} warnings", report.count(Status::Fail))
635    } else {
636        format!("doctor: no failures, {warnings} warnings")
637    });
638    lines
639}
640
641fn render_arg(arg: &OsStr) -> String {
642    match arg.to_str() {
643        Some(text) => format!("{text:?}"),
644        None => format!("{:?} (non-UTF-8)", arg.to_string_lossy()),
645    }
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651    use crate::adapter::ProfilePath;
652    use crate::launch::LaunchPlan;
653    use crate::name::{AgentId, Platform, ProfileName};
654    use crate::resolve::resolve;
655    use std::ffi::OsString;
656    use std::path::PathBuf;
657
658    use crate::adapter::{AdapterEvidence, CapabilityClaim, Mechanism};
659
660    /// A registry-shaped fixture: one claim per capability, as `metadata_invariants` requires of a real
661    /// adapter. `Fake` is not usable here — it is `#[cfg(debug_assertions)]` and this module is `cfg(test)`.
662    static TEST_METADATA: AdapterMetadata = AdapterMetadata {
663        id: "fake",
664        executable: "fake-agent",
665        mechanism: Mechanism::Env("FAKE_AGENT_HOME"),
666        support: SupportLevel::Proven,
667        evidence: AdapterEvidence {
668            mechanism_id: "fake-home-v1",
669            verified_at: "2026-09-16",
670            upstream_version: "0.0.0",
671            source_url: "measured",
672            notes: "test fixture",
673        },
674        capabilities: &[
675            CapabilityClaim {
676                capability: Capability::ConfigIsolation,
677                state: CapabilityState::Supported,
678                basis: "measured: the fixture writes only under the profile directory",
679            },
680            CapabilityClaim {
681                capability: Capability::CredentialIsolation,
682                state: CapabilityState::Unknown,
683                basis: "unmeasured: the fixture has no credentials",
684            },
685            CapabilityClaim {
686                capability: Capability::StateIsolation,
687                state: CapabilityState::NotGuaranteed,
688                basis: "measured: the fixture keeps no state",
689            },
690        ],
691        env: &[],
692        conflicts: &[],
693        credential_env: &[],
694    };
695
696    /// The report line beginning `<label>:`. Assertions that used a hard-coded index all broke when the
697    /// isolation block shifted every position; looking the field up by name keeps them from breaking again.
698    fn field<'a>(lines: &'a [String], label: &str) -> &'a str {
699        let prefix = format!("{label}:");
700        lines
701            .iter()
702            .find(|line| line.starts_with(&prefix))
703            .unwrap_or_else(|| panic!("no {label}: line in {lines:?}"))
704    }
705
706    /// The seven lines every report now carries between `mechanism:` and `environment:`.
707    fn isolation_block() -> Vec<String> {
708        vec![
709            "support:      proven".to_owned(),
710            "isolation:    config: supported".to_owned(),
711            "                measured: the fixture writes only under the profile directory"
712                .to_owned(),
713            "              credentials: unknown".to_owned(),
714            "                unmeasured: the fixture has no credentials".to_owned(),
715            "              state: not guaranteed".to_owned(),
716            "                measured: the fixture keeps no state".to_owned(),
717        ]
718    }
719
720    fn planned(
721        env: Vec<(OsString, OsString)>,
722        sensitive: Vec<OsString>,
723        exists: bool,
724    ) -> PlannedLaunch {
725        let dir = PathBuf::from("/root/profiles/work/fake");
726        PlannedLaunch {
727            plan: LaunchPlan {
728                executable: PathBuf::from("/bin/fake-agent"),
729                args: vec!["--foo".into(), "a b".into()],
730                env,
731                cwd: None,
732            },
733            profile: ProfileName::parse("work", Platform::Unix).unwrap(),
734            profile_dir: dir.clone(),
735            paths: vec![ProfilePath { path: dir, kind: PathKind::Dir, existed: exists }],
736            executable_origin: Origin::Configured,
737            mechanism: "environment variable FAKE_AGENT_HOME".to_owned(),
738            sensitive_env: sensitive,
739            notes: Vec::new(),
740        }
741    }
742
743    fn resolution() -> Resolution {
744        resolve(
745            AgentId::parse("fake").unwrap(),
746            Some(ProfileName::parse("work", Platform::Unix).unwrap()),
747            &crate::config::Config::default(),
748            &Discovery::NotInRepository,
749        )
750    }
751
752    fn home_env() -> Vec<(OsString, OsString)> {
753        vec![("FAKE_AGENT_HOME".into(), "/root/profiles/work/fake".into())]
754    }
755
756    #[test]
757    fn report_has_every_spec_26_field() {
758        let lines = report_lines(
759            &planned(home_env(), vec![], false),
760            &resolution(),
761            ReportMode::DryRun,
762            &TEST_METADATA,
763        );
764        let dir = PathBuf::from("/root/profiles/work/fake");
765        let mut expected = vec![
766            "agent:        fake".to_owned(),
767            "profile:      work (explicit)".to_owned(),
768            format!("executable:   {} (configured)", PathBuf::from("/bin/fake-agent").display()),
769            "repository:   none".to_owned(),
770            "mechanism:    environment variable FAKE_AGENT_HOME".to_owned(),
771        ];
772        expected.extend(isolation_block());
773        expected.extend([
774            "environment:  FAKE_AGENT_HOME=/root/profiles/work/fake (would be created)".to_owned(),
775            format!("creates:      {} (would be created)", dir.display()),
776            "arguments:    [\"--foo\", \"a b\"]".to_owned(),
777        ]);
778        assert_eq!(lines, expected);
779        let existing = report_lines(
780            &planned(home_env(), vec![], true),
781            &resolution(),
782            ReportMode::DryRun,
783            &TEST_METADATA,
784        );
785        assert_eq!(
786            field(&existing, "environment"),
787            "environment:  FAKE_AGENT_HOME=/root/profiles/work/fake"
788        );
789        assert_eq!(field(&existing, "creates"), "creates:      none");
790    }
791
792    #[test]
793    fn verbose_has_no_markers_and_no_creates_line() {
794        let lines = report_lines(
795            &planned(home_env(), vec![], false),
796            &resolution(),
797            ReportMode::Verbose,
798            &TEST_METADATA,
799        );
800        assert_eq!(lines.len(), 14, "{lines:?}");
801        let text = lines.join("\n");
802        assert!(!text.contains("(would be created)"), "{text}");
803        assert!(!text.contains("creates:"), "{text}");
804    }
805
806    #[test]
807    fn several_missing_paths_and_notes_are_listed_in_order() {
808        let mut planned = planned(Vec::new(), vec![], false);
809        let file = PathBuf::from("/root/profiles/work/aider/.aider.conf.yml");
810        planned.paths.push(ProfilePath {
811            path: file.clone(),
812            kind: PathKind::File { contents: b"{}\n" },
813            existed: false,
814        });
815        planned.notes = vec!["first note".to_owned(), "second note".to_owned()];
816        let lines = report_lines(&planned, &resolution(), ReportMode::DryRun, &TEST_METADATA);
817        assert_eq!(field(&lines, "environment"), "environment:  none");
818        assert_eq!(
819            field(&lines, "creates"),
820            format!("creates:      {} (would be created)", planned.paths[0].path.display())
821        );
822        assert_eq!(lines[14], format!("              {} (would be created)", file.display()));
823        assert_eq!(
824            lines.iter().filter(|line| line.starts_with("note:")).collect::<Vec<_>>(),
825            ["note:         first note", "note:         second note"]
826        );
827        let verbose = report_lines(&planned, &resolution(), ReportMode::Verbose, &TEST_METADATA);
828        assert_eq!(verbose.last().unwrap(), "note:         second note");
829    }
830
831    #[test]
832    fn sensitive_argument_values_are_redacted_in_the_report_only() {
833        let args: Vec<std::ffi::OsString> = [
834            "--api-key",
835            "anthropic=sk-1",
836            "--openai-api-key=sk-2",
837            "--set-env",
838            "ANTHROPIC_API_KEY=sk-3",
839            "--set-env=OPENAI_API_KEY=sk-4",
840            "--",
841            "GITHUB_TOKEN=sk-5",
842            "--no-op-key",
843            "sk-6",
844            "--model",
845            "gpt",
846            "path=a=b",
847            "-c",
848            "mcp_servers.gh.env.GITHUB_TOKEN=\"sk-7\"",
849            "--config=model_providers.x.experimental_bearer_token=sk-8",
850            "--header",
851            "Authorization: Bearer sk-9",
852            "--header=X-Api-Key:sk-10",
853            "mcp_servers.chrome-devtools.env.GITHUB_TOKEN=sk-11",
854            "mcp_servers.gh.http_headers.X-Api-Key=sk-12",
855            "Proxy-Authorization: Basic sk-13:with-colon",
856            "https://example.com/mcp",
857            "a.b=c",
858            "--Auth-Token",
859            "--also-hidden",
860        ]
861        .into_iter()
862        .map(Into::into)
863        .collect();
864        assert_eq!(
865            render_args(&args),
866            [
867                r#""--api-key""#,
868                r#""<redacted>""#,
869                r#""--openai-api-key=<redacted>""#,
870                r#""--set-env""#,
871                r#""ANTHROPIC_API_KEY=<redacted>""#,
872                r#""--set-env=OPENAI_API_KEY=<redacted>""#,
873                r#""--""#,
874                r#""GITHUB_TOKEN=<redacted>""#,
875                r#""--no-op-key""#,
876                r#""<redacted>""#,
877                r#""--model""#,
878                r#""gpt""#,
879                r#""path=a=b""#,
880                r#""-c""#,
881                r#""mcp_servers.gh.env.GITHUB_TOKEN=<redacted>""#,
882                r#""--config=model_providers.x.experimental_bearer_token=<redacted>""#,
883                r#""--header""#,
884                r#""Authorization: <redacted>""#,
885                r#""--header=X-Api-Key: <redacted>""#,
886                r#""mcp_servers.chrome-devtools.env.GITHUB_TOKEN=<redacted>""#,
887                r#""mcp_servers.gh.http_headers.X-Api-Key=<redacted>""#,
888                r#""Proxy-Authorization: <redacted>""#,
889                r#""https://example.com/mcp""#,
890                r#""a.b=c""#,
891                r#""--Auth-Token""#,
892                r#""<redacted>""#,
893            ]
894        );
895        let mut planned = planned(Vec::new(), vec![], true);
896        planned.plan.args = args.clone();
897        let text =
898            report_lines(&planned, &resolution(), ReportMode::Verbose, &TEST_METADATA).join("\n");
899        for secret in ["sk-", "also-hidden"] {
900            assert!(!text.contains(secret), "{text}");
901        }
902        assert_eq!(planned.plan.args, args, "the launched arguments never change");
903    }
904
905    #[test]
906    fn every_sensitive_name_part_and_padded_headers_are_redacted() {
907        let args: Vec<std::ffi::OsString> = [
908            "--client-secret=a",
909            "--db-password=b",
910            "--credential-file=c",
911            "--x-token=d",
912            "--api-key=e",
913            "--auth=f",
914            "Authorization: Basic dXNlcjpwYXNz==",
915        ]
916        .into_iter()
917        .map(Into::into)
918        .collect();
919        assert_eq!(
920            render_args(&args),
921            [
922                r#""--client-secret=<redacted>""#,
923                r#""--db-password=<redacted>""#,
924                r#""--credential-file=<redacted>""#,
925                r#""--x-token=<redacted>""#,
926                r#""--api-key=<redacted>""#,
927                r#""--auth=<redacted>""#,
928                r#""Authorization: <redacted>""#,
929            ]
930        );
931    }
932
933    #[test]
934    fn a_hidden_secret_option_still_hides_its_own_value() {
935        let args: Vec<std::ffi::OsString> = [
936            "--api-key",
937            "--client-secret",
938            "sk-live",
939            "--model",
940            "gpt",
941            "--token",
942            "GITHUB_TOKEN=x",
943            "--shown",
944        ]
945        .into_iter()
946        .map(Into::into)
947        .collect();
948        assert_eq!(
949            render_args(&args),
950            [
951                r#""--api-key""#,
952                r#""<redacted>""#,
953                r#""<redacted>""#,
954                r#""--model""#,
955                r#""gpt""#,
956                r#""--token""#,
957                r#""<redacted>""#,
958                r#""--shown""#,
959            ]
960        );
961    }
962
963    #[test]
964    fn redaction_by_declaration_and_by_name() {
965        let env: Vec<(OsString, OsString)> = vec![
966            ("PLAIN".into(), "visible".into()),
967            ("DECLARED".into(), "hidden-1".into()),
968            ("MY_api_Token".into(), "hidden-2".into()),
969            ("GITHUB_AUTH".into(), "hidden-3".into()),
970        ];
971        let lines = report_lines(
972            &planned(env, vec!["DECLARED".into()], true),
973            &resolution(),
974            ReportMode::DryRun,
975            &TEST_METADATA,
976        );
977        let text = lines.join("\n");
978        assert!(text.contains("PLAIN=visible"), "{text}");
979        for secret in ["hidden-1", "hidden-2", "hidden-3"] {
980            assert!(!text.contains(secret), "{text}");
981        }
982        assert_eq!(text.matches("<redacted>").count(), 3, "{text}");
983        assert!(lines[13].starts_with("              DECLARED="), "{text}");
984    }
985
986    /// The Unix spelling on Unix, the Windows spelling on Windows.
987    fn host(unix: &str, windows: &str) -> PathBuf {
988        PathBuf::from(if cfg!(windows) { windows } else { unix })
989    }
990
991    fn config(text: &str) -> Config {
992        let dir = tempfile::tempdir().unwrap();
993        std::fs::write(dir.path().join("config.toml"), text).unwrap();
994        Config::load(&crate::config::AppRoot::from_path(dir.path().to_path_buf())).unwrap()
995    }
996
997    fn key(path: &Path) -> String {
998        toml_edit::Key::new(path.to_str().unwrap()).display_repr().into_owned()
999    }
1000
1001    fn name(text: &str) -> ProfileName {
1002        ProfileName::parse(text, Platform::host()).unwrap()
1003    }
1004
1005    #[test]
1006    fn resolve_lines_show_the_source_and_none() {
1007        let acme = host("/src/acme", r"C:\src\acme");
1008        let text = format!("[repositories.{}]\nagents = {{ claude = \"personal\" }}\n", key(&acme));
1009        let config = config(&text);
1010        let claude = AgentId::parse("claude").unwrap();
1011        let inside = resolve(claude.clone(), None, &config, &Discovery::Repository(acme.clone()));
1012        assert_eq!(
1013            resolve_lines(&inside),
1014            [
1015                "agent:        claude".to_owned(),
1016                "profile:      personal".to_owned(),
1017                "source:       repository agent mapping".to_owned(),
1018                format!("repository:   {}", acme.display()),
1019            ]
1020        );
1021        let outside = resolve(claude, None, &config, &Discovery::NotInRepository);
1022        assert_eq!(
1023            resolve_lines(&outside),
1024            [
1025                "agent:        claude",
1026                "profile:      none",
1027                "source:       none",
1028                "repository:   none"
1029            ]
1030        );
1031    }
1032
1033    #[test]
1034    fn status_lines_show_mappings_the_default_each_agent_and_ancestor_notes() {
1035        let src = host("/src", r"C:\src");
1036        let acme = src.join("acme");
1037        let text = format!(
1038            "default_profile = \"work\"\n[repositories.{}]\nprofile = \"work\"\nagents = {{ codex = \"b\", claude = \"personal\" }}\n[repositories.{}]\nprofile = \"outer\"\n[repositories.{}]\nprofile = \"root\"\n[repositories.{}]\nprofile = \"sibling\"\n",
1039            key(&acme),
1040            key(&src),
1041            key(&host("/", r"C:\")),
1042            key(&host("/src/acme-2", r"C:\src\acme-2")),
1043        );
1044        let config = config(&text);
1045        let discovery = Discovery::Repository(acme.clone());
1046        let rows: Vec<(Resolution, Option<String>)> = ["claude", "aider"]
1047            .into_iter()
1048            .map(|id| (resolve(AgentId::parse(id).unwrap(), None, &config, &discovery), None))
1049            .collect();
1050        assert_eq!(
1051            status_lines(&discovery, &config, &rows),
1052            [
1053                format!("repository:   {}", acme.display()),
1054                "mapping:      work".to_owned(),
1055                "agents:       claude=personal, codex=b".to_owned(),
1056                "default:      work".to_owned(),
1057                "claude:       personal (repository agent mapping)".to_owned(),
1058                "aider:        work (repository mapping)".to_owned(),
1059                format!(
1060                    "note:         {} has a mapping that does not apply to this repository",
1061                    host("/", r"C:\").display()
1062                ),
1063                format!(
1064                    "note:         {} has a mapping that does not apply to this repository",
1065                    src.display()
1066                ),
1067            ]
1068        );
1069    }
1070
1071    #[test]
1072    fn agent_status_lines_add_presence_and_show_none_outside_a_repository() {
1073        let config = config("");
1074        let claude = AgentId::parse("claude").unwrap();
1075        let none = resolve(claude.clone(), None, &config, &Discovery::NotInRepository);
1076        assert_eq!(
1077            status_lines(&Discovery::NotInRepository, &config, &[(none, None)]),
1078            [
1079                "repository:   not in a repository",
1080                "mapping:      none",
1081                "agents:       none",
1082                "default:      none",
1083                "claude:       none",
1084            ]
1085        );
1086        let explicit = resolve(claude, Some(name("work")), &config, &Discovery::NotInRepository);
1087        let lines = status_lines(
1088            &Discovery::NotInRepository,
1089            &config,
1090            &[(explicit, Some("conflicts with Work".to_owned()))],
1091        );
1092        assert_eq!(
1093            &lines[4..],
1094            ["claude:       work (explicit)", "presence:     conflicts with Work"]
1095        );
1096    }
1097
1098    #[test]
1099    fn link_lines_cover_every_outcome() {
1100        let acme = host("/src/acme", r"C:\src\acme");
1101        let claude = AgentId::parse("claude").unwrap();
1102        let shown = acme.display();
1103        for (outcome, agent, expected) in [
1104            (LinkOutcome::Linked, None, format!("linked {shown} -> work")),
1105            (LinkOutcome::Linked, Some(&claude), format!("linked claude: {shown} -> work")),
1106            (
1107                LinkOutcome::Changed { old: name("personal") },
1108                None,
1109                format!("changed {shown}: personal -> work"),
1110            ),
1111            (
1112                LinkOutcome::Changed { old: name("personal") },
1113                Some(&claude),
1114                format!("changed claude: {shown}: personal -> work"),
1115            ),
1116            (LinkOutcome::AlreadyLinked, None, format!("already linked {shown} -> work")),
1117            (
1118                LinkOutcome::AlreadyLinked,
1119                Some(&claude),
1120                format!("already linked claude: {shown} -> work"),
1121            ),
1122        ] {
1123            assert_eq!(link_line(&outcome, agent, &acme, &name("work")), expected);
1124        }
1125    }
1126
1127    #[test]
1128    fn unlink_lines_cover_every_outcome_and_both_notes() {
1129        let src = host("/src", r"C:\src");
1130        let acme = src.join("acme");
1131        let claude = AgentId::parse("claude").unwrap();
1132        let beta = src.join("beta");
1133        let text = format!(
1134            "[repositories.{}]\nprofile = \"outer\"\n[repositories.{}]\nagents = {{ codex = \"b\", claude = \"personal\" }}\n[repositories.{}]\nprofile = \"work\"\n",
1135            key(&src),
1136            key(&acme),
1137            key(&beta)
1138        );
1139        let config = config(&text);
1140        // `beta` has a repository profile and no agent mappings, so nothing is left behind to report.
1141        let removed = UnlinkOutcome::Unlinked { root: beta.clone(), old: name("work") };
1142        assert_eq!(
1143            unlink_lines(&removed, None, &config, std::slice::from_ref(&beta)),
1144            [format!("unlinked {} (was work)", beta.display())]
1145        );
1146        assert_eq!(
1147            unlink_lines(&removed, Some(&claude), &config, std::slice::from_ref(&beta)),
1148            [format!("unlinked claude: {} (was work)", beta.display())]
1149        );
1150        let nothing = UnlinkOutcome::NothingToRemove { shown: acme.clone() };
1151        let ancestor = format!(
1152            "note:         {} has a mapping; remove it with agent-profile unlink --repo {}",
1153            src.display(),
1154            src.display()
1155        );
1156        assert_eq!(
1157            unlink_lines(&nothing, None, &config, std::slice::from_ref(&acme)),
1158            [
1159                format!("no mapping to remove for {}", acme.display()),
1160                ancestor.clone(),
1161                "note:         agent mappings remain: claude=personal, codex=b; remove them with \
1162                 agent-profile <agent> unlink"
1163                    .to_owned(),
1164            ]
1165        );
1166        assert_eq!(
1167            unlink_lines(&nothing, Some(&claude), &config, std::slice::from_ref(&acme)),
1168            [format!("no mapping to remove for claude: {}", acme.display()), ancestor]
1169        );
1170    }
1171
1172    #[test]
1173    fn a_repository_unlink_names_the_agent_mappings_it_leaves_behind() {
1174        let src = host("/src", r"C:\src");
1175        let acme = src.join("acme");
1176        let claude = AgentId::parse("claude").unwrap();
1177        // The configuration is the one read BEFORE the command: a repository profile and agent mappings.
1178        let before = config(&format!(
1179            "[repositories.{}]\nprofile = \"work\"\nagents = {{ codex = \"b\", claude = \"personal\" }}\n",
1180            key(&acme)
1181        ));
1182        let removed = UnlinkOutcome::Unlinked { root: acme.clone(), old: name("work") };
1183        assert_eq!(
1184            unlink_lines(&removed, None, &before, std::slice::from_ref(&acme)),
1185            [
1186                format!("unlinked {} (was work)", acme.display()),
1187                "note:         agent mappings remain: claude=personal, codex=b; remove them with \
1188                 agent-profile <agent> unlink"
1189                    .to_owned(),
1190            ]
1191        );
1192        // An agent unlink removes one agent's mapping and says nothing about the others.
1193        let removed_agent = UnlinkOutcome::Unlinked { root: acme.clone(), old: name("personal") };
1194        assert_eq!(
1195            unlink_lines(&removed_agent, Some(&claude), &before, std::slice::from_ref(&acme)),
1196            [format!("unlinked claude: {} (was personal)", acme.display())]
1197        );
1198    }
1199
1200    #[test]
1201    fn the_unlink_hint_quotes_a_path_with_whitespace() {
1202        let spaced = host("/my repos", r"C:\my repos");
1203        let inner = spaced.join("inner");
1204        let text = format!("[repositories.{}]\nprofile = \"outer\"\n", key(&spaced));
1205        let config = config(&text);
1206        let nothing = UnlinkOutcome::NothingToRemove { shown: inner.clone() };
1207        assert_eq!(
1208            unlink_lines(&nothing, None, &config, std::slice::from_ref(&inner))[1],
1209            format!(
1210                "note:         {} has a mapping; remove it with agent-profile unlink --repo \"{}\"",
1211                spaced.display(),
1212                spaced.display()
1213            )
1214        );
1215    }
1216
1217    #[test]
1218    fn create_line_covers_both_outcomes() {
1219        let work = ProfileName::parse("work", Platform::host()).unwrap();
1220        let dir = Path::new("/root/profiles/work/fake");
1221        assert_eq!(
1222            create_line(&CreateOutcome::Created, "fake", &work, dir),
1223            format!("created profile 'work' for fake: {}", dir.display())
1224        );
1225        assert_eq!(
1226            create_line(&CreateOutcome::AlreadyExists, "fake", &work, dir),
1227            format!("profile 'work' for fake already exists: {}", dir.display())
1228        );
1229    }
1230
1231    #[cfg(unix)]
1232    #[test]
1233    fn non_utf8_argument_is_rendered_lossily_with_marker() {
1234        use std::os::unix::ffi::OsStringExt;
1235        assert_eq!(render_arg(&OsString::from_vec(vec![0x66, 0xff])), "\"f\u{fffd}\" (non-UTF-8)");
1236    }
1237
1238    #[cfg(windows)]
1239    #[test]
1240    fn non_utf8_argument_is_rendered_lossily_with_marker() {
1241        use std::os::windows::ffi::OsStringExt;
1242        assert_eq!(render_arg(&OsString::from_wide(&[0x66, 0xD800])), "\"f\u{fffd}\" (non-UTF-8)");
1243    }
1244
1245    /// `TEST_METADATA` with the support level and the state-isolation claim replaced.
1246    ///
1247    /// The hedge's own branches are unreachable through any shipped adapter: `fake` is the only
1248    /// non-`Proven` one and all three of its claims are `Unknown`, so nothing in the workspace ever sends
1249    /// a `NotGuaranteed` claim through `support_hedge`. That is the branch §10 asks for a fixture for.
1250    fn hedged(support: SupportLevel, state: CapabilityState) -> AdapterMetadata {
1251        const CLAIMS: [CapabilityClaim; 3] = [
1252            CapabilityClaim {
1253                capability: Capability::ConfigIsolation,
1254                state: CapabilityState::Supported,
1255                basis: "measured: the fixture writes only under the profile directory",
1256            },
1257            CapabilityClaim {
1258                capability: Capability::CredentialIsolation,
1259                state: CapabilityState::Unknown,
1260                basis: "unmeasured: the fixture has no credentials",
1261            },
1262            CapabilityClaim {
1263                capability: Capability::StateIsolation,
1264                state: CapabilityState::NotGuaranteed,
1265                basis: "measured: the fixture keeps no state",
1266            },
1267        ];
1268        let mut claims = CLAIMS;
1269        claims[2].state = state;
1270        // `capabilities` is `&'static [_]`, so the modified claims have to outlive this call.
1271        let leaked: &'static [CapabilityClaim] = Box::leak(Box::new(claims));
1272        AdapterMetadata { support, capabilities: leaked, ..TEST_METADATA }
1273    }
1274
1275    #[test]
1276    fn a_proven_adapter_does_not_hedge() {
1277        assert_eq!(
1278            support_hedge(&hedged(SupportLevel::Proven, CapabilityState::NotGuaranteed)),
1279            None
1280        );
1281    }
1282
1283    /// The regression the design names: an earlier draft listed only `Unknown` capabilities, which would
1284    /// have hedged about an adapter whose configuration demonstrably leaks without ever saying so.
1285    #[test]
1286    fn the_hedge_names_every_capability_that_is_not_supported() {
1287        let hedge =
1288            support_hedge(&hedged(SupportLevel::Experimental, CapabilityState::NotGuaranteed))
1289                .expect("a non-proven adapter hedges");
1290        assert_eq!(
1291            hedge,
1292            "fake is experimental: credentials unknown, state not guaranteed. \
1293             Run with --dry-run for detail.",
1294            "{hedge}"
1295        );
1296    }
1297
1298    #[test]
1299    fn the_hedge_lists_only_the_capabilities_that_are_weak() {
1300        // `CredentialIsolation` stays `Unknown` in the fixture, so that is all this should name.
1301        assert_eq!(
1302            support_hedge(&hedged(SupportLevel::Experimental, CapabilityState::Supported))
1303                .as_deref(),
1304            Some("fake is experimental: credentials unknown. Run with --dry-run for detail.")
1305        );
1306    }
1307
1308    /// A missing claim must render, not vanish: dropping it silently would let an adapter that declared
1309    /// nothing at all for a capability produce the same reassuring hedge as one that measured it and found
1310    /// it `Supported`.
1311    #[test]
1312    fn the_hedge_names_a_capability_that_was_never_declared() {
1313        let mut metadata = hedged(SupportLevel::Experimental, CapabilityState::Supported);
1314        let claims: Vec<CapabilityClaim> = metadata
1315            .capabilities
1316            .iter()
1317            .filter(|claim| claim.capability != Capability::CredentialIsolation)
1318            .copied()
1319            .collect();
1320        let leaked: &'static [CapabilityClaim] = Box::leak(claims.into_boxed_slice());
1321        metadata.capabilities = leaked;
1322        let hedge = support_hedge(&metadata).expect("a non-proven adapter hedges");
1323        assert!(
1324            hedge.contains(&format!(
1325                "{} not declared",
1326                capability_label(Capability::CredentialIsolation)
1327            )),
1328            "{hedge}"
1329        );
1330    }
1331
1332    /// `report_lines` is reachable from a fixture that declares no capabilities at all
1333    /// (`adapter/mod.rs`'s `SECRETIVE`). A lookup-and-unwrap would panic there; a silent skip would make
1334    /// "not claimed" indistinguishable from "not rendered".
1335    #[test]
1336    fn a_capability_with_no_claim_renders_as_not_declared() {
1337        let metadata = AdapterMetadata { capabilities: &[], ..TEST_METADATA };
1338        let launch = planned(home_env(), Vec::new(), false);
1339        let lines = report_lines(&launch, &resolution(), ReportMode::DryRun, &metadata);
1340        assert!(field(&lines, "isolation").contains("config: not declared"), "{lines:?}");
1341        assert!(
1342            lines.iter().all(|line| !line.contains("measured:") && !line.contains("unmeasured:")),
1343            "a capability with no claim has no basis line: {lines:?}"
1344        );
1345    }
1346
1347    /// Every `CapabilityState`, in report order. Kept next to the two tests below so both walk the same
1348    /// list.
1349    const EVERY_STATE: [CapabilityState; 5] = [
1350        CapabilityState::Supported,
1351        CapabilityState::NotSupported,
1352        CapabilityState::NotGuaranteed,
1353        CapabilityState::Conditional,
1354        CapabilityState::Unknown,
1355    ];
1356
1357    /// Pins `state_label`'s five literal spellings. Nothing else in the suite exercises the strings
1358    /// `"not supported"` or `"conditional"` directly: collapsing either arm onto another word left all
1359    /// other tests green, because `support_hedge` (the only caller) never distinguishes the wording, only
1360    /// whether the string is present.
1361    #[test]
1362    fn every_capability_state_has_its_pinned_report_spelling() {
1363        let pairs = [
1364            (CapabilityState::Supported, "supported"),
1365            (CapabilityState::NotSupported, "not supported"),
1366            (CapabilityState::NotGuaranteed, "not guaranteed"),
1367            (CapabilityState::Conditional, "conditional"),
1368            (CapabilityState::Unknown, "unknown"),
1369        ];
1370        for (state, expected) in pairs {
1371            assert_eq!(state_label(state), expected, "{state:?}");
1372        }
1373        assert_eq!(pairs.len(), EVERY_STATE.len());
1374    }
1375
1376    /// No two states share a spelling — checked on `state_label`'s own output, with nothing compared to a
1377    /// literal first.
1378    ///
1379    /// That ordering is the whole point. This used to be the tail of the test above, running over a list
1380    /// appended to only *after* each entry had been asserted equal to one of five pairwise-distinct
1381    /// literals; the distinctness assertion therefore held unconditionally and no mutant could redden it.
1382    /// Split out, collapsing two `state_label` arms onto one word fails here on its own.
1383    #[test]
1384    fn every_capability_state_has_a_distinct_report_spelling() {
1385        // Exhaustiveness anchor: a wildcard-free `match`, so adding a sixth `CapabilityState` stops this
1386        // test compiling. The arm must then name a position in `EVERY_STATE`; one past the end panics on
1387        // the index and one already taken fails the assertion, so the variant has to be added to the list
1388        // rather than quietly left out of the distinctness check below.
1389        fn position(state: CapabilityState) -> usize {
1390            match state {
1391                CapabilityState::Supported => 0,
1392                CapabilityState::NotSupported => 1,
1393                CapabilityState::NotGuaranteed => 2,
1394                CapabilityState::Conditional => 3,
1395                CapabilityState::Unknown => 4,
1396            }
1397        }
1398        for (index, state) in EVERY_STATE.into_iter().enumerate() {
1399            assert_eq!(EVERY_STATE[position(state)], state, "{state:?}");
1400            assert_eq!(position(state), index, "{state:?} is out of place in EVERY_STATE");
1401        }
1402
1403        let spellings: Vec<&str> = EVERY_STATE.into_iter().map(state_label).collect();
1404        let mut distinct = spellings.clone();
1405        distinct.sort_unstable();
1406        distinct.dedup();
1407        assert_eq!(
1408            distinct.len(),
1409            spellings.len(),
1410            "spellings must be pairwise distinct: {spellings:?}"
1411        );
1412    }
1413
1414    /// A `NotSupported` claim must hedge as "not supported", not as anything more reassuring. The expected
1415    /// capability word comes from `capability_label` (so a label rename doesn't break this), but "not
1416    /// supported" is hard-coded: routing it through `state_label` here would make the assertion mutate in
1417    /// lockstep with the function under test and never go red.
1418    #[test]
1419    fn the_hedge_spells_a_not_supported_claim_as_not_supported() {
1420        const CLAIMS: [CapabilityClaim; 3] = [
1421            CapabilityClaim {
1422                capability: Capability::ConfigIsolation,
1423                state: CapabilityState::Supported,
1424                basis: "measured: the fixture writes only under the profile directory",
1425            },
1426            CapabilityClaim {
1427                capability: Capability::CredentialIsolation,
1428                state: CapabilityState::NotSupported,
1429                basis: "measured: credentials stay in the default location",
1430            },
1431            CapabilityClaim {
1432                capability: Capability::StateIsolation,
1433                state: CapabilityState::Supported,
1434                basis: "measured: the fixture keeps no state",
1435            },
1436        ];
1437        let leaked: &'static [CapabilityClaim] = Box::leak(Box::new(CLAIMS));
1438        let metadata = AdapterMetadata {
1439            support: SupportLevel::Experimental,
1440            capabilities: leaked,
1441            ..TEST_METADATA
1442        };
1443        let hedge = support_hedge(&metadata).expect("a non-proven adapter hedges");
1444        assert!(
1445            hedge.contains(&format!(
1446                "{} not supported",
1447                capability_label(Capability::CredentialIsolation)
1448            )),
1449            "{hedge}"
1450        );
1451    }
1452
1453    /// The `(would be created)` marker names only the env value that equals a missing `Dir` entry — not a
1454    /// value that merely matches no entry, and not one that equals a missing `File` entry (the predicate
1455    /// requires `Dir`). Every shipped fixture happened to have exactly one env entry whose value was the
1456    /// one missing path, so `true` in place of the predicate was indistinguishable from it.
1457    #[test]
1458    fn the_would_be_created_marker_names_only_the_path_that_will_be_created() {
1459        let dir = PathBuf::from("/root/profiles/work/fake");
1460        let file = PathBuf::from("/root/profiles/work/fake/.fake.conf");
1461        let elsewhere = PathBuf::from("/somewhere/else");
1462        let launch = PlannedLaunch {
1463            plan: LaunchPlan {
1464                executable: PathBuf::from("/bin/fake-agent"),
1465                args: vec!["--foo".into()],
1466                env: vec![
1467                    ("DIR_VAR".into(), dir.clone().into_os_string()),
1468                    ("ELSEWHERE_VAR".into(), elsewhere.into_os_string()),
1469                    ("FILE_VAR".into(), file.clone().into_os_string()),
1470                ],
1471                cwd: None,
1472            },
1473            profile: ProfileName::parse("work", Platform::Unix).unwrap(),
1474            profile_dir: dir.clone(),
1475            paths: vec![
1476                ProfilePath { path: dir, kind: PathKind::Dir, existed: false },
1477                ProfilePath {
1478                    path: file,
1479                    kind: PathKind::File { contents: b"{}\n" },
1480                    existed: false,
1481                },
1482            ],
1483            executable_origin: Origin::Configured,
1484            mechanism: "environment variable FAKE_AGENT_HOME".to_owned(),
1485            sensitive_env: Vec::new(),
1486            notes: Vec::new(),
1487        };
1488        let lines = report_lines(&launch, &resolution(), ReportMode::DryRun, &TEST_METADATA);
1489        let dir_line = lines.iter().find(|line| line.contains("DIR_VAR=")).expect("DIR_VAR line");
1490        assert!(dir_line.contains("(would be created)"), "{dir_line}");
1491        let elsewhere_line =
1492            lines.iter().find(|line| line.contains("ELSEWHERE_VAR=")).expect("ELSEWHERE_VAR line");
1493        assert!(!elsewhere_line.contains("(would be created)"), "{elsewhere_line}");
1494        let file_line =
1495            lines.iter().find(|line| line.contains("FILE_VAR=")).expect("FILE_VAR line");
1496        assert!(!file_line.contains("(would be created)"), "{file_line}");
1497    }
1498
1499    #[test]
1500    fn report_lines_embed_capability_lines_unchanged() {
1501        let lines = report_lines(
1502            &planned(home_env(), vec![], false),
1503            &resolution(),
1504            ReportMode::DryRun,
1505            &TEST_METADATA,
1506        );
1507        let matrix = capability_lines(&TEST_METADATA);
1508        let start = lines
1509            .iter()
1510            .position(|line| *line == matrix[0])
1511            .expect("the matrix starts in the report");
1512        assert_eq!(&lines[start..start + matrix.len()], matrix.as_slice(), "{lines:?}");
1513    }
1514
1515    #[test]
1516    fn doctor_lines_group_and_summarize() {
1517        use crate::doctor::{Check, CheckId, Report, Status};
1518        let check = |id, agent, status, message: &str| Check {
1519            id,
1520            agent,
1521            subject: None,
1522            status,
1523            message: message.to_owned(),
1524            data: serde_json::Value::Null,
1525        };
1526        let report = Report {
1527            agent: None,
1528            profile: None,
1529            repository: None,
1530            checks: vec![
1531                check(CheckId::Platform, None, Status::Info, "linux x86_64"),
1532                check(CheckId::Config, None, Status::Fail, "config is unreadable"),
1533                check(CheckId::Mapping, None, Status::Pass, "mapping ok"),
1534                check(CheckId::Executable, Some("fake"), Status::Warn, "not found"),
1535                check(CheckId::Support, Some("fake"), Status::Pass, "proven"),
1536            ],
1537        };
1538        assert_eq!(
1539            doctor_lines(&report),
1540            [
1541                "host",
1542                "INFO  linux x86_64",
1543                "FAIL  config is unreadable",
1544                "",
1545                "repositories",
1546                "PASS  mapping ok",
1547                "",
1548                "agent fake",
1549                "WARN  [fake] not found",
1550                "PASS  [fake] proven",
1551                "",
1552                "doctor: 1 failed, 1 warnings",
1553            ]
1554        );
1555        let clean =
1556            Report { checks: vec![check(CheckId::Platform, None, Status::Warn, "w")], ..report };
1557        assert_eq!(doctor_lines(&clean).last().unwrap(), "doctor: no failures, 1 warnings");
1558    }
1559
1560    #[test]
1561    fn doctor_lines_place_skips_and_agentless_repository_records() {
1562        use crate::doctor::{Check, CheckId, Report, Status};
1563        let check = |id, agent, status, message: &str| Check {
1564            id,
1565            agent,
1566            subject: None,
1567            status,
1568            message: message.to_owned(),
1569            data: serde_json::Value::Null,
1570        };
1571        let report = Report {
1572            agent: Some(crate::name::AgentId::parse("fake").unwrap()),
1573            profile: None,
1574            repository: None,
1575            checks: vec![
1576                check(CheckId::Platform, None, Status::Info, "linux x86_64"),
1577                check(CheckId::Repository, None, Status::Fail, "no repository"),
1578                check(CheckId::Mapping, None, Status::Skip, "mapping skipped: config failed"),
1579                check(CheckId::MappingAgent, None, Status::Warn, "unknown agent"),
1580                check(CheckId::Resolution, Some("fake"), Status::Skip, "resolution skipped"),
1581            ],
1582        };
1583        assert_eq!(
1584            doctor_lines(&report),
1585            [
1586                "host",
1587                "INFO  linux x86_64",
1588                "",
1589                "repositories",
1590                "FAIL  no repository",
1591                "SKIP  mapping skipped: config failed",
1592                "WARN  unknown agent",
1593                "",
1594                "agent fake",
1595                "SKIP  [fake] resolution skipped",
1596                "",
1597                "doctor: 1 failed, 1 warnings",
1598            ]
1599        );
1600    }
1601}