1use 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
25const SENSITIVE_NAME_PARTS: [&str; 6] =
27 ["TOKEN", "SECRET", "KEY", "PASSWORD", "CREDENTIAL", "AUTH"];
28
29const LABEL_WIDTH: usize = 14;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum ReportMode {
34 DryRun,
36 Verbose,
38}
39
40pub fn support_label(support: SupportLevel) -> &'static str {
42 match support {
43 SupportLevel::Proven => "proven",
44 SupportLevel::Experimental => "experimental",
45 }
46}
47
48pub 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
57pub 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
68pub 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
94pub 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
159fn origin_word(origin: Origin) -> &'static str {
161 match origin {
162 Origin::Configured => "configured",
163 Origin::Path => "PATH",
164 }
165}
166
167pub 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 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
193pub(crate) fn labeled(label: &str, value: impl std::fmt::Display) -> String {
195 format!("{:<LABEL_WIDTH$}{value}", format!("{label}:"))
196}
197
198pub 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
207pub 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
217pub 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
259pub(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
268pub 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
284pub 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 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
323fn 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
336pub 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
352pub 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
364pub fn missing_profile_line(agent: &str, profile: &ProfileName) -> String {
366 format!("profile '{profile}' for {agent} does not exist; nothing to delete")
367}
368
369pub 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
391pub 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
404pub 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
455pub 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
508enum Shown {
510 Verbatim,
511 Redacted(String),
513 HidesNext,
515}
516
517fn 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 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
569fn 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
583fn 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
592pub 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 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 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 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 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 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(¬hing, 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(¬hing, 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 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 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(¬hing, 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 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 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 #[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 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 #[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 #[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 const EVERY_STATE: [CapabilityState; 5] = [
1350 CapabilityState::Supported,
1351 CapabilityState::NotSupported,
1352 CapabilityState::NotGuaranteed,
1353 CapabilityState::Conditional,
1354 CapabilityState::Unknown,
1355 ];
1356
1357 #[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 #[test]
1384 fn every_capability_state_has_a_distinct_report_spelling() {
1385 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 #[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 #[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}