1use std::ffi::OsStr;
13use std::fs;
14use std::io;
15use std::path::{Path, PathBuf};
16
17use serde_json::{Value, json};
18
19use crate::adapter::{
20 self, Adapter, AdapterMetadata, Capability, CapabilityState, Mechanism, PathKind,
21};
22use crate::config::{AppRoot, Config};
23use crate::error::Error;
24use crate::exe::{self, ExecutableStatus, Origin};
25use crate::json;
26use crate::lifecycle::{self, Presence};
27use crate::name::Platform;
28use crate::name::{AgentId, ProfileName};
29use crate::output;
30use crate::repo::{self, Discovery};
31use crate::repositories::{self, State};
32use crate::resolve;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Status {
37 Pass,
38 Info,
39 Warn,
40 Fail,
41 Skip,
42}
43
44impl Status {
45 pub fn token(self) -> &'static str {
46 match self {
47 Status::Pass => "pass",
48 Status::Info => "info",
49 Status::Warn => "warn",
50 Status::Fail => "fail",
51 Status::Skip => "skip",
52 }
53 }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum CheckId {
59 Platform,
60 AppRoot,
61 AppRootWritable,
62 Config,
63 ConfigAgent,
64 ProfilesDir,
65 CaseTwins,
66 DefaultProfile,
67 Repository,
68 Mapping,
69 MappingAgent,
70 Executable,
71 RepositoryLocalExecutable,
72 Support,
73 Resolution,
74 Profile,
75 ProfilePath,
76 LaunchEnvironment,
77 ConflictingOption,
78 AmbientCredential,
79 Capability,
80}
81
82impl CheckId {
83 pub fn token(self) -> &'static str {
84 match self {
85 CheckId::Platform => "platform",
86 CheckId::AppRoot => "app_root",
87 CheckId::AppRootWritable => "app_root_writable",
88 CheckId::Config => "config",
89 CheckId::ConfigAgent => "config_agent",
90 CheckId::ProfilesDir => "profiles_dir",
91 CheckId::CaseTwins => "case_twins",
92 CheckId::DefaultProfile => "default_profile",
93 CheckId::Repository => "repository",
94 CheckId::Mapping => "mapping",
95 CheckId::MappingAgent => "mapping_agent",
96 CheckId::Executable => "executable",
97 CheckId::RepositoryLocalExecutable => "repository_local_executable",
98 CheckId::Support => "support",
99 CheckId::Resolution => "resolution",
100 CheckId::Profile => "profile",
101 CheckId::ProfilePath => "profile_path",
102 CheckId::LaunchEnvironment => "launch_environment",
103 CheckId::ConflictingOption => "conflicting_option",
104 CheckId::AmbientCredential => "ambient_credential",
105 CheckId::Capability => "capability",
106 }
107 }
108}
109
110#[derive(Debug, Clone, PartialEq)]
112pub struct Check {
113 pub id: CheckId,
114 pub agent: Option<&'static str>,
115 pub subject: Option<String>,
116 pub status: Status,
117 pub message: String,
118 pub data: Value,
120}
121
122pub struct Environment<'a> {
124 pub root: std::result::Result<AppRoot, String>,
126 pub path_var: Option<&'a OsStr>,
127 pub cwd: &'a Path,
129 pub start: &'a Path,
131 pub is_set: &'a dyn Fn(&str) -> bool,
133}
134
135pub enum Target {
137 Host,
138 Agent { agent: AgentId, profile: Option<String> },
139}
140
141pub struct Report {
143 pub agent: Option<AgentId>,
144 pub profile: Option<ProfileName>,
146 pub repository: Option<PathBuf>,
147 pub checks: Vec<Check>,
148}
149
150impl Report {
151 pub fn failed(&self) -> bool {
152 self.count(Status::Fail) > 0
153 }
154
155 pub fn count(&self, status: Status) -> usize {
156 self.checks.iter().filter(|check| check.status == status).count()
157 }
158}
159
160pub fn run(target: &Target, env: &Environment<'_>) -> Report {
162 let mut report = Report {
163 agent: match target {
164 Target::Host => None,
165 Target::Agent { agent, .. } => Some(agent.clone()),
166 },
167 profile: None,
168 repository: None,
169 checks: Vec::new(),
170 };
171 let host = host_checks(env, &mut report.checks);
172 let discovery = repository_checks(env, &host, &mut report.checks);
173 report.repository = match &discovery {
174 Some(Discovery::Repository(root)) => Some(root.clone()),
175 _ => None,
176 };
177 match target {
178 Target::Host => {
179 for adapter in adapter::registry() {
180 let metadata = adapter.metadata();
181 executable_checks(metadata, env, &host, false, &mut report.checks);
182 report.checks.push(support_check(metadata));
183 }
184 }
185 Target::Agent { agent, profile } => {
186 let adapter = adapter::lookup(agent.as_str()).expect("the grammar admits known agents");
187 let metadata = adapter.metadata();
188 executable_checks(metadata, env, &host, true, &mut report.checks);
189 report.checks.push(support_check(metadata));
190 let resolved = resolution_check(
191 agent,
192 profile.as_deref(),
193 &host,
194 discovery.as_ref(),
195 &mut report.checks,
196 );
197 report.profile = resolved.clone();
198 let blocked = if host.root.is_none() { CheckId::AppRoot } else { CheckId::Resolution };
199 profile_checks(adapter, resolved.as_ref(), env, &host, blocked, &mut report.checks);
200 option_checks(metadata, env, &mut report.checks);
201 }
202 }
203 report
204}
205
206struct Host {
208 root: Option<AppRoot>,
209 config: Option<Config>,
210 profiles_failed: bool,
212}
213
214fn check(
215 id: CheckId,
216 subject: Option<String>,
217 status: Status,
218 message: String,
219 data: Value,
220) -> Check {
221 Check { id, agent: None, subject, status, message, data }
222}
223
224fn skip(id: CheckId, dependency: CheckId) -> Check {
225 check(
226 id,
227 None,
228 Status::Skip,
229 format!("{} skipped: {} failed", id.token(), dependency.token()),
230 Value::Null,
231 )
232}
233
234fn shown(path: &Path) -> String {
235 path.display().to_string()
236}
237
238fn nearest_existing(path: &Path) -> Option<PathBuf> {
242 let mut current = path;
243 loop {
244 match fs::metadata(current) {
245 Err(error)
246 if matches!(
247 error.kind(),
248 io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
249 ) =>
250 {
251 current = current.parent()?;
252 }
253 _ => return Some(current.to_owned()),
254 }
255 }
256}
257
258#[derive(Clone, Copy)]
260enum Blocker {
261 DanglingLink,
262 NotDirectory,
263}
264
265impl Blocker {
266 fn describe(self) -> &'static str {
267 match self {
268 Blocker::DanglingLink => "a dangling symbolic link",
269 Blocker::NotDirectory => "not a directory",
270 }
271 }
272}
273
274fn blocked_ancestor(path: &Path) -> Option<(PathBuf, Blocker)> {
277 let ancestor = nearest_existing(path)?;
278 for candidate in path.ancestors().take_while(|candidate| *candidate != ancestor) {
279 if fs::symlink_metadata(candidate).is_ok() {
280 return Some((candidate.to_owned(), Blocker::DanglingLink));
281 }
282 }
283 match fs::metadata(&ancestor) {
284 Ok(metadata) if !metadata.is_dir() => Some((ancestor, Blocker::NotDirectory)),
285 _ => None,
286 }
287}
288
289fn host_checks(env: &Environment<'_>, checks: &mut Vec<Check>) -> Host {
290 checks.push(check(
291 CheckId::Platform,
292 None,
293 Status::Info,
294 format!(
295 "{} {}, agent-profile {}",
296 std::env::consts::OS,
297 std::env::consts::ARCH,
298 json::TOOL_VERSION
299 ),
300 json!({ "os": std::env::consts::OS, "arch": std::env::consts::ARCH }),
301 ));
302 let mut host = Host { root: None, config: None, profiles_failed: false };
303 let root = match &env.root {
304 Ok(root) => root,
305 Err(message) => {
306 checks.push(check(
307 CheckId::AppRoot,
308 None,
309 Status::Fail,
310 format!("cannot resolve the application root: {message}"),
311 json!({ "path": Value::Null, "exists": Value::Null }),
312 ));
313 for id in [
314 CheckId::AppRootWritable,
315 CheckId::Config,
316 CheckId::ConfigAgent,
317 CheckId::ProfilesDir,
318 CheckId::CaseTwins,
319 CheckId::DefaultProfile,
320 ] {
321 checks.push(skip(id, CheckId::AppRoot));
322 }
323 return host;
324 }
325 };
326 let root_ok = app_root(root, checks);
327 if !root_ok {
328 for id in [
329 CheckId::AppRootWritable,
330 CheckId::Config,
331 CheckId::ConfigAgent,
332 CheckId::ProfilesDir,
333 CheckId::CaseTwins,
334 CheckId::DefaultProfile,
335 ] {
336 checks.push(skip(id, CheckId::AppRoot));
337 }
338 return host;
339 }
340 host.root = Some(root.clone());
341 app_root_writable(root, checks);
342 host.config = config_checks(root, checks);
343 match profiles_dir(root, checks) {
344 ProfilesDir::Present => case_twins(root, checks),
345 ProfilesDir::Absent => {}
346 ProfilesDir::Failed => {
347 host.profiles_failed = true;
348 checks.push(skip(CheckId::CaseTwins, CheckId::ProfilesDir));
349 }
350 }
351 match &host.config {
352 Some(config) => checks.push(default_profile(config)),
353 None => checks.push(skip(CheckId::DefaultProfile, CheckId::Config)),
354 }
355 host
356}
357
358fn app_root(root: &AppRoot, checks: &mut Vec<Check>) -> bool {
360 let path = root.path();
361 let subject = Some(shown(path));
362 let (status, message, exists) = match fs::metadata(path) {
363 Ok(metadata) if metadata.is_dir() => {
364 (Status::Pass, format!("application root {} is a directory", path.display()), true)
365 }
366 Ok(_) => (
367 Status::Fail,
368 format!(
369 "application root {} exists and is not a directory; remove it or set AGENT_PROFILE_HOME",
370 path.display()
371 ),
372 true,
373 ),
374 Err(error)
375 if matches!(error.kind(), io::ErrorKind::NotFound | io::ErrorKind::NotADirectory) =>
376 {
377 if fs::symlink_metadata(path).is_ok() {
378 (
379 Status::Fail,
380 format!(
381 "application root {} is a dangling symbolic link; remove it or set AGENT_PROFILE_HOME",
382 path.display()
383 ),
384 false,
385 )
386 } else if let Some((blocker, reason)) = blocked_ancestor(path) {
387 (
388 Status::Fail,
389 format!(
390 "application root {} cannot be created: {} is {}; remove it or set AGENT_PROFILE_HOME",
391 path.display(),
392 blocker.display(),
393 reason.describe()
394 ),
395 false,
396 )
397 } else {
398 (
399 Status::Info,
400 format!(
401 "application root {} does not exist; it is created on first use",
402 path.display()
403 ),
404 false,
405 )
406 }
407 }
408 Err(error) => (
409 Status::Fail,
410 format!("cannot examine the application root {}: {error}", path.display()),
411 true,
412 ),
413 };
414 let usable = status != Status::Fail;
415 checks.push(check(
416 CheckId::AppRoot,
417 subject,
418 status,
419 message,
420 json!({ "path": shown(path), "exists": exists }),
421 ));
422 usable
423}
424
425fn app_root_writable(root: &AppRoot, checks: &mut Vec<Check>) {
426 let path = root.path();
427 let subject = Some(shown(path));
428 let exists = fs::metadata(path).is_ok();
429 let data = json!({ "path": shown(path), "exists": exists });
430 #[cfg(windows)]
431 {
432 checks.push(check(
433 CheckId::AppRootWritable,
434 subject,
435 Status::Info,
436 format!("writability of {} is not checked on this platform", path.display()),
437 data,
438 ));
439 }
440 #[cfg(not(windows))]
441 {
442 let probe = nearest_existing(path).unwrap_or_else(|| path.to_owned());
443 let (status, message) = match fs::metadata(&probe) {
444 Ok(metadata) if metadata.permissions().readonly() => (
445 Status::Fail,
446 format!(
447 "{} has no write permission bit, so lazy initialization and create cannot create anything under {}; \
448 change its permissions (only permission bits were read, never ACLs or ownership)",
449 probe.display(),
450 path.display()
451 ),
452 ),
453 Ok(_) => (
454 Status::Pass,
455 format!(
456 "{} has a write permission bit (only permission bits were read, never ACLs or ownership)",
457 probe.display()
458 ),
459 ),
460 Err(error) => (
461 Status::Fail,
462 format!(
463 "cannot examine {} to decide whether {} is writable: {error}",
464 probe.display(),
465 path.display()
466 ),
467 ),
468 };
469 checks.push(check(CheckId::AppRootWritable, subject, status, message, data));
470 }
471}
472
473fn config_checks(root: &AppRoot, checks: &mut Vec<Check>) -> Option<Config> {
475 let path = root.config_path();
476 let subject = Some(shown(&path));
477 let exists = fs::metadata(&path).is_ok();
478 let data = json!({ "path": shown(&path), "exists": exists });
479 match Config::load(root) {
480 Ok(config) => {
481 let (status, message) = if exists {
482 (Status::Pass, format!("configuration {} is valid", path.display()))
483 } else {
484 (Status::Info, format!("no configuration at {}; defaults apply", path.display()))
485 };
486 checks.push(check(CheckId::Config, subject, status, message, data));
487 let known = adapter::known_agents();
488 for id in config.configured_agents() {
489 if !known.contains(&id) {
490 checks.push(check(
491 CheckId::ConfigAgent,
492 Some(id.to_owned()),
493 Status::Warn,
494 format!(
495 "`{id}` in {} is not a known agent; this table is ignored",
496 path.display()
497 ),
498 Value::Null,
499 ));
500 }
501 }
502 Some(config)
503 }
504 Err(error) => {
505 checks.push(check(CheckId::Config, subject, Status::Fail, error.to_string(), data));
506 checks.push(skip(CheckId::ConfigAgent, CheckId::Config));
507 None
508 }
509 }
510}
511
512fn default_profile(config: &Config) -> Check {
513 match config.default_profile() {
514 Some(name) => check(
515 CheckId::DefaultProfile,
516 Some(name.to_string()),
517 Status::Info,
518 format!("the global default profile is `{name}`"),
519 Value::Null,
520 ),
521 None => check(
522 CheckId::DefaultProfile,
523 None,
524 Status::Info,
525 "no global default profile is set".to_owned(),
526 Value::Null,
527 ),
528 }
529}
530
531enum ProfilesDir {
533 Present,
534 Absent,
535 Failed,
537}
538
539fn profiles_dir(root: &AppRoot, checks: &mut Vec<Check>) -> ProfilesDir {
541 let dir = root.profiles_dir();
542 let adapters = adapter::registry();
543 match lifecycle::list_profiles(root, &adapters) {
544 Ok(listing) => {
545 let exists = fs::metadata(&dir).is_ok();
546 let dangling = !exists && fs::symlink_metadata(&dir).is_ok();
547 let (status, message) = if dangling {
548 (
549 Status::Fail,
550 format!(
551 "profiles directory {} is a dangling symbolic link; remove it",
552 dir.display()
553 ),
554 )
555 } else if exists {
556 (
557 Status::Pass,
558 format!(
559 "profiles directory {} lists {} profile(s), {} entr(ies) ignored",
560 dir.display(),
561 listing.rows.len(),
562 listing.ignored
563 ),
564 )
565 } else {
566 (
567 Status::Info,
568 format!(
569 "profiles directory {} does not exist; it is created on first use",
570 dir.display()
571 ),
572 )
573 };
574 checks.push(check(
575 CheckId::ProfilesDir,
576 Some(shown(&dir)),
577 status,
578 message,
579 json!({
580 "path": shown(&dir),
581 "exists": exists,
582 "profiles": listing.rows.len(),
583 "ignored": listing.ignored,
584 }),
585 ));
586 if dangling {
587 ProfilesDir::Failed
588 } else if exists {
589 ProfilesDir::Present
590 } else {
591 ProfilesDir::Absent
592 }
593 }
594 Err(error) => {
595 let (subject, itself) = match fs::read_dir(&dir) {
596 Err(_) => (dir.clone(), true),
597 Ok(_) => match unreadable_profile(&dir) {
598 Some(child) => (child, false),
599 None => (dir.clone(), true),
600 },
601 };
602 checks.push(check(
603 CheckId::ProfilesDir,
604 Some(shown(&subject)),
605 Status::Fail,
606 format!("{error}; fix the permissions of {} or remove it", subject.display()),
607 json!({
608 "path": shown(&subject),
609 "exists": true,
610 "profiles": Value::Null,
611 "ignored": Value::Null,
612 }),
613 ));
614 if itself { ProfilesDir::Failed } else { ProfilesDir::Present }
615 }
616 }
617}
618
619fn unreadable_profile(dir: &Path) -> Option<PathBuf> {
622 let mut children: Vec<(String, PathBuf)> = fs::read_dir(dir)
623 .ok()?
624 .flatten()
625 .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
626 .filter_map(|entry| {
627 let name = entry.file_name().to_str()?.to_owned();
628 (!name.starts_with('.') && ProfileName::parse(&name, Platform::host()).is_ok())
629 .then(|| (name, entry.path()))
630 })
631 .collect();
632 children.sort_by(|(a, _), (b, _)| a.as_bytes().cmp(b.as_bytes()));
633 children.into_iter().map(|(_, path)| path).find(
634 |path| matches!(fs::read_dir(path), Err(error) if error.kind() != io::ErrorKind::NotFound),
635 )
636}
637
638fn case_twins(root: &AppRoot, checks: &mut Vec<Check>) {
640 let Ok(entries) = fs::read_dir(root.profiles_dir()) else {
641 return;
642 };
643 let mut names: Vec<String> = entries
644 .flatten()
645 .filter_map(|entry| entry.file_name().to_str().map(str::to_owned))
646 .collect();
647 names.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
648 for (index, a) in names.iter().enumerate() {
649 for b in &names[index + 1..] {
650 if a.eq_ignore_ascii_case(b) {
651 checks.push(check(
652 CheckId::CaseTwins,
653 Some(a.clone()),
654 Status::Fail,
655 format!(
656 "profiles `{a}` and `{b}` differ only in case; every command on either exits 4; rename or remove one"
657 ),
658 json!({ "names": [a, b] }),
659 ));
660 }
661 }
662 }
663}
664
665fn repository_checks(
667 env: &Environment<'_>,
668 host: &Host,
669 checks: &mut Vec<Check>,
670) -> Option<Discovery> {
671 let start = env.start;
672 let found = match repo::discover(start) {
673 Ok(Discovery::Repository(root)) => {
674 checks.push(check(
675 CheckId::Repository,
676 Some(shown(start)),
677 Status::Info,
678 format!("{} is in the repository rooted at {}", start.display(), root.display()),
679 Value::Null,
680 ));
681 Some(Discovery::Repository(root))
682 }
683 Ok(Discovery::NotInRepository) => {
684 checks.push(check(
685 CheckId::Repository,
686 Some(shown(start)),
687 Status::Info,
688 format!("{} is not in a repository", start.display()),
689 Value::Null,
690 ));
691 Some(Discovery::NotInRepository)
692 }
693 Err(error) => {
694 let reason = match &error {
695 Error::Repository { reason, .. } => reason.clone(),
696 other => other.to_string(),
697 };
698 checks.push(check(
699 CheckId::Repository,
700 Some(shown(start)),
701 Status::Fail,
702 format!("cannot discover the repository from {}: {reason}", start.display()),
703 Value::Null,
704 ));
705 None
706 }
707 };
708 mapping_checks(host, checks);
709 found
710}
711
712fn mapping_checks(host: &Host, checks: &mut Vec<Check>) {
713 let (Some(root), Some(config)) = (&host.root, &host.config) else {
714 checks.push(skip(CheckId::Mapping, mapping_dependency(host)));
715 checks.push(skip(CheckId::MappingAgent, mapping_dependency(host)));
716 return;
717 };
718 let entries = repositories::report(root, config);
719 for entry in &entries {
720 let key = &entry.key;
721 let detail = entry.detail.as_deref().unwrap_or("");
722 let hint = "run `agent-profile repositories` and edit config.toml";
723 let (status, message) = match entry.state {
724 State::Active => (Status::Pass, format!("mapping {key} is active")),
725 State::Moved => (
726 Status::Warn,
727 format!(
728 "mapping {key} is below the repository rooted at {detail}, so it no longer applies; {hint}"
729 ),
730 ),
731 State::Missing => (
732 Status::Warn,
733 format!("mapping {key} names a directory that does not exist; {hint}"),
734 ),
735 State::NotARepository => (
736 Status::Warn,
737 format!("mapping {key} is a directory that is not a repository; {hint}"),
738 ),
739 State::Unreadable => {
740 (Status::Warn, format!("mapping {key} cannot be examined ({detail}); {hint}"))
741 }
742 State::Foreign => (
743 Status::Info,
744 format!(
745 "mapping {key} is a path of another platform; it is kept but never applies here"
746 ),
747 ),
748 };
749 let data = serde_json::to_value(json::repository_view(entry)).unwrap_or(Value::Null);
750 checks.push(check(CheckId::Mapping, Some(key.clone()), status, message, data));
751 }
752 for entry in &entries {
753 for (agent, reference) in &entry.agents {
754 if !reference.known {
755 checks.push(check(
756 CheckId::MappingAgent,
757 Some(format!("{} {agent}", entry.key)),
758 Status::Warn,
759 format!(
760 "mapping {} names `{agent}`, which is not a known agent; edit config.toml",
761 entry.key
762 ),
763 Value::Null,
764 ));
765 }
766 }
767 }
768}
769
770fn mapping_dependency(host: &Host) -> CheckId {
772 if host.root.is_none() { CheckId::AppRoot } else { CheckId::Config }
773}
774
775fn agent_check(
777 agent: &'static str,
778 id: CheckId,
779 subject: Option<String>,
780 status: Status,
781 message: String,
782 data: Value,
783) -> Check {
784 Check { agent: Some(agent), ..check(id, subject, status, message, data) }
785}
786
787fn agent_skip(agent: &'static str, id: CheckId, dependency: CheckId) -> Check {
788 Check { agent: Some(agent), ..skip(id, dependency) }
789}
790
791fn executable_checks(
793 metadata: &AdapterMetadata,
794 env: &Environment<'_>,
795 host: &Host,
796 targeted: bool,
797 checks: &mut Vec<Check>,
798) {
799 let id = metadata.id;
800 if let (Some(root), Some(config)) = (&host.root, &host.config) {
801 let explicit = config.agent_executable(id);
802 checks.push(
803 match ExecutableStatus::discover(metadata, explicit, env.path_var, &root.config_path())
804 {
805 Ok(status) => executable_record(metadata, &status, targeted),
806 Err(error) => agent_check(
807 id,
808 CheckId::Executable,
809 None,
810 Status::Fail,
811 format!("cannot discover the executable of {id}: {error}"),
812 Value::Null,
813 ),
814 },
815 );
816 } else {
817 checks.push(agent_skip(id, CheckId::Executable, mapping_dependency(host)));
818 }
819 if let Some(path) = exe::repository_local(metadata.executable, env.path_var, env.cwd) {
820 checks.push(agent_check(
821 id,
822 CheckId::RepositoryLocalExecutable,
823 Some(shown(&path)),
824 Status::Warn,
825 format!(
826 "{} is found but never used: v0.1 searches only the configured override and absolute PATH entries",
827 path.display()
828 ),
829 Value::Null,
830 ));
831 }
832}
833
834fn executable_record(
835 metadata: &AdapterMetadata,
836 status: &ExecutableStatus,
837 targeted: bool,
838) -> Check {
839 let id = metadata.id;
840 let (subject, level, message) = match status {
841 ExecutableStatus::Found(found) if cfg!(unix) && !exe::is_executable(&found.path) => (
842 Some(shown(&found.path)),
843 Status::Fail,
844 format!(
845 "the configured executable {path} has no execute permission bit; chmod +x {path} or fix [agents.{id}] executable in config.toml",
846 path = found.path.display()
847 ),
848 ),
849 ExecutableStatus::Found(found) => (
850 Some(shown(&found.path)),
851 Status::Pass,
852 format!(
853 "found {} ({})",
854 found.path.display(),
855 match found.origin {
856 Origin::Configured => "configured override",
857 Origin::Path => "on PATH",
858 }
859 ),
860 ),
861 ExecutableStatus::NotOnPath => (
862 None,
863 if targeted { Status::Fail } else { Status::Info },
864 format!(
865 "{} was not found in any absolute PATH entry; install it or set [agents.{id}] executable in config.toml",
866 metadata.executable
867 ),
868 ),
869 ExecutableStatus::ConfiguredMissing(path) => (
870 Some(shown(path)),
871 Status::Fail,
872 format!(
873 "the configured executable {} does not exist or is not a file; fix [agents.{id}] executable in config.toml",
874 path.display()
875 ),
876 ),
877 ExecutableStatus::Unsupported(path) => (
878 Some(shown(path)),
879 Status::Fail,
880 format!(
881 "{} is a shell script or shim, which a launch refuses (exit 6); point [agents.{id}] executable at a native executable",
882 path.display()
883 ),
884 ),
885 };
886 let data = serde_json::to_value(json::executable_view(status)).unwrap_or(Value::Null);
887 agent_check(id, CheckId::Executable, subject, level, message, data)
888}
889
890fn support_check(metadata: &AdapterMetadata) -> Check {
892 let view = serde_json::to_value(json::agent_view(metadata, &ExecutableStatus::NotOnPath))
893 .unwrap_or(Value::Null);
894 let data = json!({
895 "mechanism": view["mechanism"],
896 "support": view["support"],
897 "evidence": view["evidence"],
898 "capabilities": view["capabilities"],
899 "installed_version": Value::Null,
900 "version_probe": "not_run",
901 });
902 agent_check(
903 metadata.id,
904 CheckId::Support,
905 None,
906 Status::Info,
907 format!(
908 "{} is {} against upstream version {} verified {}; mechanism {}; the installed version is not probed",
909 metadata.id,
910 json::support_token(metadata.support),
911 metadata.evidence.upstream_version,
912 metadata.evidence.verified_at,
913 metadata.evidence.mechanism_id
914 ),
915 data,
916 )
917}
918
919fn resolution_check(
921 agent: &AgentId,
922 argument: Option<&str>,
923 host: &Host,
924 discovery: Option<&Discovery>,
925 checks: &mut Vec<Check>,
926) -> Option<ProfileName> {
927 let id = adapter::lookup(agent.as_str()).expect("known agent").metadata().id;
928 let Some(config) = &host.config else {
929 checks.push(agent_skip(id, CheckId::Resolution, mapping_dependency(host)));
930 return None;
931 };
932 let explicit = match argument.map(|raw| (raw, ProfileName::parse(raw, Platform::host()))) {
933 Some((raw, Err(reason))) => {
934 let error = Error::InvalidProfileName { name: raw.to_owned(), reason };
935 checks.push(agent_check(
936 id,
937 CheckId::Resolution,
938 Some(raw.to_owned()),
939 Status::Fail,
940 format!("{error}; give a valid profile name"),
941 Value::Null,
942 ));
943 return None;
944 }
945 Some((_, Ok(name))) => Some(name),
946 None => None,
947 };
948 let found = match (&explicit, discovery) {
949 (Some(_), _) => Discovery::NotInRepository,
950 (None, Some(found)) => found.clone(),
951 (None, None) => {
952 checks.push(agent_skip(id, CheckId::Resolution, CheckId::Repository));
953 return None;
954 }
955 };
956 let resolution = resolve::resolve(agent.clone(), explicit, config, &found);
957 match resolution.profile {
958 Some(name) => {
959 checks.push(agent_check(
960 id,
961 CheckId::Resolution,
962 Some(name.to_string()),
963 Status::Info,
964 format!("profile `{name}` resolves for {id} ({})", resolution.source.label()),
965 Value::Null,
966 ));
967 Some(name)
968 }
969 None => {
970 checks.push(agent_check(
971 id,
972 CheckId::Resolution,
973 None,
974 Status::Warn,
975 format!(
976 "no profile resolves for {id} here; a launch here needs an explicit profile"
977 ),
978 Value::Null,
979 ));
980 None
981 }
982 }
983}
984
985fn profile_checks(
987 adapter: &dyn Adapter,
988 profile: Option<&ProfileName>,
989 env: &Environment<'_>,
990 host: &Host,
991 blocked: CheckId,
992 checks: &mut Vec<Check>,
993) {
994 let id = adapter.metadata().id;
995 let (Some(profile), Some(root)) = (profile, &host.root) else {
996 for check in [CheckId::Profile, CheckId::ProfilePath, CheckId::LaunchEnvironment] {
997 checks.push(agent_skip(id, check, blocked));
998 }
999 return;
1000 };
1001 let agent = AgentId::parse(id).expect("registry ids are valid");
1002 let paths = adapter.paths(root, profile);
1003 if host.profiles_failed {
1004 checks.push(agent_skip(id, CheckId::Profile, CheckId::ProfilesDir));
1005 checks.push(agent_skip(id, CheckId::ProfilePath, CheckId::ProfilesDir));
1006 } else {
1007 checks.push(profile_record(id, root, &agent, profile));
1008 for (path, kind) in &paths {
1009 checks.push(profile_path_record(id, path, *kind));
1010 }
1011 }
1012 checks.push(launch_environment(adapter.metadata(), &paths, env));
1013}
1014
1015fn profile_record(
1016 id: &'static str,
1017 root: &AppRoot,
1018 agent: &AgentId,
1019 profile: &ProfileName,
1020) -> Check {
1021 let presence = lifecycle::presence(root, agent, profile);
1022 let existing = match &presence {
1023 Presence::Conflict { existing } => json!(existing),
1024 _ => Value::Null,
1025 };
1026 let (status, message) = match &presence {
1027 Presence::Conflict { existing } => (
1028 Status::Fail,
1029 format!(
1030 "profile `{profile}` conflicts with `{existing}`, which differs only in case; rename or remove one"
1031 ),
1032 ),
1033 Presence::Absent => (
1034 Status::Info,
1035 format!("profile `{profile}` does not exist for {id}; it is created on first launch"),
1036 ),
1037 Presence::Materialized | Presence::Known => {
1038 (Status::Pass, format!("profile `{profile}` exists for {id}"))
1039 }
1040 };
1041 agent_check(
1042 id,
1043 CheckId::Profile,
1044 Some(profile.to_string()),
1045 status,
1046 message,
1047 json!({ "state": presence.token(), "existing": existing }),
1048 )
1049}
1050
1051#[cfg(unix)]
1053fn file_mode(metadata: &fs::Metadata) -> Value {
1054 use std::os::unix::fs::PermissionsExt;
1055 json!(format!("{:o}", metadata.permissions().mode() & 0o7777))
1056}
1057
1058#[cfg(not(unix))]
1059fn file_mode(_metadata: &fs::Metadata) -> Value {
1060 Value::Null
1061}
1062
1063fn profile_path_record(id: &'static str, path: &Path, kind: PathKind) -> Check {
1064 let (kind_token, noun) = match kind {
1065 PathKind::Dir => ("dir", "directory"),
1066 PathKind::File { .. } => ("file", "file"),
1067 };
1068 let mut exists = true;
1069 let mut mode = Value::Null;
1070 let (status, message) = match fs::metadata(path) {
1071 Ok(metadata) => {
1072 mode = file_mode(&metadata);
1073 let right = match kind {
1074 PathKind::Dir => metadata.is_dir(),
1075 PathKind::File { .. } => metadata.is_file(),
1076 };
1077 if !right {
1078 (
1079 Status::Fail,
1080 format!(
1081 "{} exists but is not a {noun}; a launch refuses it; remove or rename it",
1082 path.display()
1083 ),
1084 )
1085 } else if cfg!(unix) && metadata.permissions().readonly() {
1086 (
1087 Status::Warn,
1088 format!(
1089 "{} has no write permission bit, so the agent cannot write its state there",
1090 path.display()
1091 ),
1092 )
1093 } else {
1094 (Status::Pass, format!("{} is a {noun}", path.display()))
1095 }
1096 }
1097 Err(error)
1098 if matches!(error.kind(), io::ErrorKind::NotFound | io::ErrorKind::NotADirectory) =>
1099 {
1100 exists = false;
1101 missing_path(path)
1102 }
1103 Err(error) => (Status::Fail, format!("cannot examine {}: {error}", path.display())),
1104 };
1105 agent_check(
1106 id,
1107 CheckId::ProfilePath,
1108 Some(shown(path)),
1109 status,
1110 message,
1111 json!({ "path": shown(path), "exists": exists, "kind": kind_token, "mode": mode }),
1112 )
1113}
1114
1115fn missing_path(path: &Path) -> (Status, String) {
1117 let created =
1118 (Status::Info, format!("{} does not exist; it is created on first launch", path.display()));
1119 if let Some((blocker, reason)) = blocked_ancestor(path) {
1120 return match reason {
1121 Blocker::DanglingLink => (
1122 Status::Fail,
1123 format!(
1124 "{} is a dangling symbolic link, so a launch cannot create {}; remove it",
1125 blocker.display(),
1126 path.display()
1127 ),
1128 ),
1129 Blocker::NotDirectory => (
1130 Status::Fail,
1131 format!(
1132 "{} is not a directory, so a launch cannot create {}; remove or rename it",
1133 blocker.display(),
1134 path.display()
1135 ),
1136 ),
1137 };
1138 }
1139 let Some(ancestor) = nearest_existing(path) else {
1140 return created;
1141 };
1142 match fs::metadata(&ancestor) {
1143 Ok(metadata) if cfg!(unix) && metadata.permissions().readonly() => (
1144 Status::Fail,
1145 format!(
1146 "{} has no write permission bit, so a launch cannot create {}; change its permissions",
1147 ancestor.display(),
1148 path.display()
1149 ),
1150 ),
1151 Ok(_) => created,
1152 Err(error) => (Status::Fail, format!("cannot examine {}: {error}", ancestor.display())),
1153 }
1154}
1155
1156fn launch_environment(
1159 metadata: &AdapterMetadata,
1160 paths: &[(PathBuf, PathKind)],
1161 env: &Environment<'_>,
1162) -> Check {
1163 let (kind, name, wants_file) = match metadata.mechanism {
1164 Mechanism::Env(name) => ("env", name, false),
1165 Mechanism::EnvFile(name) => ("env_file", name, true),
1166 Mechanism::FlagDir(option) => ("flag_dir", option.spelling(), false),
1167 Mechanism::FlagFile(option) => ("flag_file", option.spelling(), true),
1168 };
1169 let target = paths
1170 .iter()
1171 .find(|(_, kind)| matches!(kind, PathKind::File { .. }) == wants_file)
1172 .map(|(path, _)| path.as_path())
1173 .expect("an adapter owns the path its mechanism points at");
1174 let variable = matches!(metadata.mechanism, Mechanism::Env(_) | Mechanism::EnvFile(_));
1175 let already_set = variable.then(|| (env.is_set)(name));
1176 let message = if variable {
1177 let mut message = format!("sets {name} to {}", target.display());
1178 if already_set == Some(true) {
1179 message += "; already set in this environment, the launch replaces it";
1180 }
1181 message
1182 } else {
1183 metadata.mechanism.sentence_for(target)
1184 };
1185 agent_check(
1186 metadata.id,
1187 CheckId::LaunchEnvironment,
1188 Some(name.to_owned()),
1189 Status::Info,
1190 message,
1191 json!({
1192 "kind": kind,
1193 "name": name,
1194 "value": shown(target),
1195 "already_set": already_set,
1196 }),
1197 )
1198}
1199
1200fn option_checks(metadata: &AdapterMetadata, env: &Environment<'_>, checks: &mut Vec<Check>) {
1202 let id = metadata.id;
1203 for option in metadata.conflicts.iter().chain(metadata.mechanism.conflict_option()) {
1204 checks.push(agent_check(
1205 id,
1206 CheckId::ConflictingOption,
1207 Some(option.spelling().to_owned()),
1208 Status::Info,
1209 format!("a launch refuses {} because the profile already sets it", option.spelling()),
1210 Value::Null,
1211 ));
1212 }
1213 for name in metadata.credential_env.iter().filter(|name| (env.is_set)(name)) {
1215 checks.push(agent_check(
1216 id,
1217 CheckId::AmbientCredential,
1218 Some((*name).to_owned()),
1219 Status::Info,
1220 format!(
1221 "{name} is set in this environment; {id} may use it instead of the profile's own credentials"
1222 ),
1223 json!({ "name": name }),
1224 ));
1225 }
1226 for capability in Capability::ALL {
1227 let claim = metadata
1228 .capabilities
1229 .iter()
1230 .find(|claim| claim.capability == capability)
1231 .expect("registry adapters declare every capability");
1232 if claim.state != CapabilityState::Supported {
1233 checks.push(agent_check(
1234 id,
1235 CheckId::Capability,
1236 Some(json::capability_token(capability).to_owned()),
1237 Status::Info,
1238 format!(
1239 "{} isolation is {}: {}",
1240 output::capability_label(capability),
1241 output::state_label(claim.state),
1242 claim.basis
1243 ),
1244 Value::Null,
1245 ));
1246 }
1247 }
1248}
1249
1250#[cfg(test)]
1251mod tests {
1252 use super::*;
1253
1254 fn env<'a>(root: std::result::Result<AppRoot, String>, dir: &'a Path) -> Environment<'a> {
1255 Environment { root, path_var: None, cwd: dir, start: dir, is_set: &|_| false }
1256 }
1257
1258 fn find(report: &Report, id: CheckId) -> Vec<&Check> {
1259 report.checks.iter().filter(|check| check.id == id).collect()
1260 }
1261
1262 fn only(report: &Report, id: CheckId) -> &Check {
1263 let found = find(report, id);
1264 assert_eq!(found.len(), 1, "{:?}: {:?}", id, found);
1265 found[0]
1266 }
1267
1268 fn skipped(report: &Report, id: CheckId) {
1269 let found = find(report, id);
1270 assert_eq!(found.len(), 1, "{id:?}: {found:?}");
1271 assert_eq!(found[0].status, Status::Skip, "{id:?}");
1272 assert_eq!(found[0].subject, None);
1273 }
1274
1275 struct Fixture {
1277 _dir: tempfile::TempDir,
1278 base: PathBuf,
1279 root: PathBuf,
1280 }
1281
1282 fn fixture() -> Fixture {
1283 let dir = tempfile::tempdir().unwrap();
1284 let base = repo::canonical(dir.path()).unwrap();
1285 let root = base.join("root");
1286 Fixture { _dir: dir, base, root }
1287 }
1288
1289 fn run_host(fixture: &Fixture) -> Report {
1290 let environment = env(Ok(AppRoot::from_path(fixture.root.clone())), &fixture.base);
1291 run(&Target::Host, &environment)
1292 }
1293
1294 #[cfg(unix)]
1295 fn set_mode(path: &Path, mode: u32) {
1296 use std::os::unix::fs::PermissionsExt;
1297 fs::set_permissions(path, fs::Permissions::from_mode(mode)).unwrap();
1298 }
1299
1300 fn git_dir(path: &Path) {
1301 fs::create_dir_all(path.join(".git")).unwrap();
1302 fs::write(path.join(".git").join("HEAD"), "ref: refs/heads/main\n").unwrap();
1303 }
1304
1305 #[test]
1306 fn fresh_root_is_info_not_fail() {
1307 let f = fixture();
1308 let report = run_host(&f);
1309 assert_eq!(only(&report, CheckId::AppRoot).status, Status::Info);
1310 assert_eq!(only(&report, CheckId::Config).status, Status::Info);
1311 assert_eq!(only(&report, CheckId::ProfilesDir).status, Status::Info);
1312 assert!(find(&report, CheckId::CaseTwins).is_empty());
1313 assert!(!report.failed());
1314 }
1315
1316 #[test]
1317 fn unresolvable_root_skips_its_dependents() {
1318 let f = fixture();
1319 let environment = env(Err("no home".to_owned()), &f.base);
1320 let report = run(&Target::Host, &environment);
1321 assert_eq!(only(&report, CheckId::AppRoot).status, Status::Fail);
1322 for id in [
1323 CheckId::AppRootWritable,
1324 CheckId::Config,
1325 CheckId::ConfigAgent,
1326 CheckId::DefaultProfile,
1327 CheckId::ProfilesDir,
1328 CheckId::CaseTwins,
1329 CheckId::Mapping,
1330 CheckId::MappingAgent,
1331 ] {
1332 skipped(&report, id);
1333 }
1334 assert_eq!(only(&report, CheckId::Config).message, "config skipped: app_root failed");
1335 assert_eq!(
1336 only(&report, CheckId::AppRoot).data,
1337 json!({ "path": Value::Null, "exists": Value::Null })
1338 );
1339 }
1340
1341 #[cfg(unix)]
1342 #[test]
1343 fn dangling_root_fails_and_skips_its_dependents() {
1344 let f = fixture();
1345 std::os::unix::fs::symlink(f.base.join("missing"), &f.root).unwrap();
1346 let report = run_host(&f);
1347 let found = only(&report, CheckId::AppRoot);
1348 assert_eq!(found.status, Status::Fail);
1349 assert!(found.message.contains("dangling symbolic link"), "{}", found.message);
1350 for id in [CheckId::AppRootWritable, CheckId::Config, CheckId::ProfilesDir] {
1351 skipped(&report, id);
1352 }
1353 }
1354
1355 #[cfg(unix)]
1356 #[test]
1357 fn dangling_profiles_dir_fails_and_skips_its_dependents() {
1358 let f = fixture();
1359 fs::create_dir_all(&f.root).unwrap();
1360 std::os::unix::fs::symlink(f.base.join("missing"), f.root.join("profiles")).unwrap();
1361 let report = run_host(&f);
1362 let found = only(&report, CheckId::ProfilesDir);
1363 assert_eq!(found.status, Status::Fail);
1364 assert!(found.message.contains("dangling symbolic link"), "{}", found.message);
1365 skipped(&report, CheckId::CaseTwins);
1366 }
1367
1368 #[test]
1369 fn root_that_is_a_file_fails() {
1370 let f = fixture();
1371 fs::write(&f.root, "x").unwrap();
1372 let report = run_host(&f);
1373 assert_eq!(only(&report, CheckId::AppRoot).status, Status::Fail);
1374 }
1375
1376 #[cfg(unix)]
1377 #[test]
1378 fn read_only_root_fails_on_unix() {
1379 let f = fixture();
1380 fs::create_dir_all(&f.root).unwrap();
1381 set_mode(&f.root, 0o555);
1382 if fs::write(f.root.join("probe"), "x").is_ok() {
1383 set_mode(&f.root, 0o755);
1384 println!("skipped: the read-only mode does not bite (running as root)");
1385 return;
1386 }
1387 let report = run_host(&f);
1388 set_mode(&f.root, 0o755);
1389 assert_eq!(only(&report, CheckId::AppRootWritable).status, Status::Fail);
1390 }
1391
1392 #[cfg(windows)]
1393 #[test]
1394 fn windows_writability_is_info() {
1395 let f = fixture();
1396 let report = run_host(&f);
1397 assert_eq!(only(&report, CheckId::AppRootWritable).status, Status::Info);
1398 }
1399
1400 #[test]
1401 fn invalid_config_fails_and_skips_dependents() {
1402 let f = fixture();
1403 fs::create_dir_all(&f.root).unwrap();
1404 fs::write(f.root.join("config.toml"), "x =").unwrap();
1405 let report = run_host(&f);
1406 assert_eq!(only(&report, CheckId::Config).status, Status::Fail);
1407 for id in
1408 [CheckId::ConfigAgent, CheckId::DefaultProfile, CheckId::Mapping, CheckId::MappingAgent]
1409 {
1410 skipped(&report, id);
1411 }
1412 assert_eq!(only(&report, CheckId::Mapping).message, "mapping skipped: config failed");
1413 }
1414
1415 #[test]
1416 fn unknown_configured_agent_warns() {
1417 let f = fixture();
1418 fs::create_dir_all(&f.root).unwrap();
1419 let config = format!("[agents.claud]\nexecutable = '{}'\n", f.root.join("x").display());
1421 fs::write(f.root.join("config.toml"), config).unwrap();
1422 let report = run_host(&f);
1423 let found = find(&report, CheckId::ConfigAgent);
1424 assert_eq!(found.len(), 1);
1425 assert_eq!(found[0].status, Status::Warn);
1426 assert_eq!(found[0].subject.as_deref(), Some("claud"));
1427 }
1428
1429 #[cfg(unix)]
1430 #[test]
1431 fn unreadable_profiles_dir_fails_and_skips_twins() {
1432 let f = fixture();
1433 let profiles = f.root.join("profiles");
1434 fs::create_dir_all(&profiles).unwrap();
1435 set_mode(&profiles, 0o000);
1436 if fs::read_dir(&profiles).is_ok() {
1437 set_mode(&profiles, 0o755);
1438 println!("skipped: the mode does not bite (running as root)");
1439 return;
1440 }
1441 let report = run_host(&f);
1442 set_mode(&profiles, 0o755);
1443 let found = only(&report, CheckId::ProfilesDir);
1444 assert_eq!(found.status, Status::Fail);
1445 assert_eq!(found.subject.as_deref(), Some(profiles.to_str().unwrap()));
1446 skipped(&report, CheckId::CaseTwins);
1447 }
1448
1449 #[cfg(unix)]
1450 #[test]
1451 fn unreadable_profile_child_names_it() {
1452 let f = fixture();
1453 let work = f.root.join("profiles").join("work");
1454 fs::create_dir_all(&work).unwrap();
1455 set_mode(&work, 0o000);
1456 if fs::read_dir(&work).is_ok() {
1457 set_mode(&work, 0o755);
1458 println!("skipped: the mode does not bite (running as root)");
1459 return;
1460 }
1461 let report = run_host(&f);
1462 set_mode(&work, 0o755);
1463 let found = only(&report, CheckId::ProfilesDir);
1464 assert_eq!(found.status, Status::Fail);
1465 assert_eq!(found.subject.as_deref(), Some(work.to_str().unwrap()));
1466 assert!(find(&report, CheckId::CaseTwins).iter().all(|check| check.status != Status::Skip));
1467 }
1468
1469 fn case_sensitive(dir: &Path) -> bool {
1471 let probe = dir.join("probe-a");
1472 fs::write(&probe, "x").unwrap();
1473 let sensitive = !dir.join("PROBE-A").exists();
1474 fs::remove_file(&probe).unwrap();
1475 sensitive
1476 }
1477
1478 #[test]
1479 fn case_twins_fail_per_pair() {
1480 let f = fixture();
1481 fs::create_dir_all(&f.root).unwrap();
1482 if !case_sensitive(&f.root) {
1483 println!("skipped: the filesystem is case-insensitive");
1484 return;
1485 }
1486 fs::create_dir_all(f.root.join("profiles").join("work")).unwrap();
1487 fs::create_dir_all(f.root.join("profiles").join("Work")).unwrap();
1488 let report = run_host(&f);
1489 let found = find(&report, CheckId::CaseTwins);
1490 assert_eq!(found.len(), 1);
1491 assert_eq!(found[0].status, Status::Fail);
1492 assert_eq!(found[0].data["names"], json!(["Work", "work"]));
1493 assert!(found[0].message.contains("Work") && found[0].message.contains("work"));
1494 }
1495
1496 #[cfg(unix)]
1497 #[test]
1498 fn case_twins_ignores_non_utf8_and_keeps_dot_entries() {
1499 use std::os::unix::ffi::OsStrExt;
1500 let f = fixture();
1501 let profiles = f.root.join("profiles");
1502 fs::create_dir_all(&profiles).unwrap();
1503 if !case_sensitive(&f.root) {
1504 println!("skipped: the filesystem is case-insensitive");
1505 return;
1506 }
1507 fs::write(profiles.join(".x"), "").unwrap();
1508 fs::write(profiles.join(".X"), "").unwrap();
1509 let odd = profiles.join(OsStr::from_bytes(b"bad\xff"));
1510 if fs::write(&odd, "").is_err() {
1511 println!("skipped: the filesystem refuses non-UTF-8 names");
1512 return;
1513 }
1514 let report = run_host(&f);
1515 let found = find(&report, CheckId::CaseTwins);
1516 assert_eq!(found.len(), 1);
1517 assert_eq!(found[0].status, Status::Fail);
1518 assert_eq!(found[0].data["names"], json!([".X", ".x"]));
1519 }
1520
1521 #[test]
1522 fn default_profile_is_info() {
1523 let f = fixture();
1524 fs::create_dir_all(&f.root).unwrap();
1525 fs::write(f.root.join("config.toml"), "default_profile = \"work\"\n").unwrap();
1526 let report = run_host(&f);
1527 let found = only(&report, CheckId::DefaultProfile);
1528 assert_eq!(found.status, Status::Info);
1529 assert_eq!(found.subject.as_deref(), Some("work"));
1530 }
1531
1532 fn key(path: &Path) -> String {
1533 toml_edit::Key::new(path.to_str().unwrap()).display_repr().into_owned()
1534 }
1535
1536 #[test]
1537 fn mapping_states() {
1538 let f = fixture();
1539 fs::create_dir_all(&f.root).unwrap();
1540 let acme = f.base.join("acme");
1541 git_dir(&acme);
1542 let sub = acme.join("sub");
1543 fs::create_dir_all(&sub).unwrap();
1544 let plain = f.base.join("plain");
1545 fs::create_dir_all(&plain).unwrap();
1546 let gone = f.base.join("gone");
1547 let file = f.base.join("file.txt");
1548 fs::write(&file, "x").unwrap();
1549 let foreign =
1550 if cfg!(windows) { "/srv/acme".to_owned() } else { r"C:\src\acme".to_owned() };
1551 let mut config = String::new();
1552 for (path, body) in [
1553 (acme.to_str().unwrap().to_owned(), "profile = \"work\""),
1554 (sub.to_str().unwrap().to_owned(), "profile = \"work\""),
1555 (plain.to_str().unwrap().to_owned(), "profile = \"work\""),
1556 (gone.to_str().unwrap().to_owned(), "profile = \"work\""),
1557 (file.to_str().unwrap().to_owned(), "profile = \"work\""),
1558 (foreign, "profile = \"work\""),
1559 ] {
1560 config += &format!("\n[repositories.{}]\n{body}\n", key(Path::new(&path)));
1561 }
1562 fs::write(f.root.join("config.toml"), &config).unwrap();
1563 let report = run_host(&f);
1564 let app_root = AppRoot::from_path(f.root.clone());
1565 let loaded = Config::load(&app_root).unwrap();
1566 let entries = repositories::report(&app_root, &loaded);
1567 let found = find(&report, CheckId::Mapping);
1568 assert_eq!(found.len(), entries.len());
1569 assert_eq!(entries.len(), 6);
1570 let by_key: Vec<(Status, &str)> =
1571 found.iter().map(|check| (check.status, check.subject.as_deref().unwrap())).collect();
1572 let order: Vec<&str> = entries.iter().map(|entry| entry.key.as_str()).collect();
1573 assert_eq!(by_key.iter().map(|(_, key)| *key).collect::<Vec<_>>(), order);
1574 let status_of =
1575 |path: &Path| by_key.iter().find(|(_, key)| *key == path.to_str().unwrap()).unwrap().0;
1576 assert_eq!(status_of(&acme), Status::Pass);
1577 assert_eq!(status_of(&sub), Status::Warn);
1578 let moved = found.iter().find(|check| check.subject.as_deref() == sub.to_str()).unwrap();
1579 assert!(
1580 moved.message.starts_with(&format!(
1581 "mapping {} is below the repository rooted at {}, so it no longer applies",
1582 sub.display(),
1583 acme.display()
1584 )),
1585 "{}",
1586 moved.message
1587 );
1588 assert_eq!(status_of(&gone), Status::Warn);
1589 assert_eq!(status_of(&plain), Status::Warn);
1590 assert_eq!(status_of(&file), Status::Warn);
1591 assert_eq!(
1592 by_key
1593 .iter()
1594 .find(|(_, key)| key.contains("acme") && !key.starts_with(f.base.to_str().unwrap()))
1595 .unwrap()
1596 .0,
1597 Status::Info
1598 );
1599 for (check, entry) in found.iter().zip(&entries) {
1600 assert_eq!(check.data, serde_json::to_value(json::repository_view(entry)).unwrap());
1601 }
1602 }
1603
1604 #[test]
1605 fn unknown_mapping_agent_warns() {
1606 let f = fixture();
1607 fs::create_dir_all(&f.root).unwrap();
1608 let repository = f.base.join("acme");
1609 git_dir(&repository);
1610 fs::write(
1611 f.root.join("config.toml"),
1612 format!("[repositories.{}]\nagents = {{ nosuch = \"work\" }}\n", key(&repository)),
1613 )
1614 .unwrap();
1615 let report = run_host(&f);
1616 let found = find(&report, CheckId::MappingAgent);
1617 assert_eq!(found.len(), 1);
1618 assert_eq!(found[0].status, Status::Warn);
1619 assert_eq!(found[0].subject, Some(format!("{} nosuch", repository.display())));
1620 }
1621
1622 #[test]
1623 fn repository_discovery_error_fails() {
1624 let f = fixture();
1625 let missing = f.base.join("missing");
1626 let environment = Environment {
1627 root: Ok(AppRoot::from_path(f.root.clone())),
1628 path_var: None,
1629 cwd: &f.base,
1630 start: &missing,
1631 is_set: &|_| false,
1632 };
1633 let report = run(&Target::Host, &environment);
1634 assert_eq!(only(&report, CheckId::Repository).status, Status::Fail);
1635 assert_eq!(report.repository, None);
1636 }
1637
1638 fn fake_id() -> AgentId {
1641 AgentId::parse("fake").unwrap()
1642 }
1643
1644 fn bin_dir(f: &Fixture, installed: bool) -> PathBuf {
1646 let dir = f.base.join(if installed { "bin-installed" } else { "bin-empty" });
1647 fs::create_dir_all(&dir).unwrap();
1648 if installed {
1649 let file = dir.join(if cfg!(windows) { "fake-agent.exe" } else { "fake-agent" });
1650 fs::write(&file, "x").unwrap();
1651 #[cfg(unix)]
1652 set_mode(&file, 0o755);
1653 }
1654 dir
1655 }
1656
1657 fn run_with(
1658 f: &Fixture,
1659 target: &Target,
1660 path_var: Option<&OsStr>,
1661 is_set: &dyn Fn(&str) -> bool,
1662 ) -> Report {
1663 let environment = Environment {
1664 root: Ok(AppRoot::from_path(f.root.clone())),
1665 path_var,
1666 cwd: &f.base,
1667 start: &f.base,
1668 is_set,
1669 };
1670 run(target, &environment)
1671 }
1672
1673 fn agent_target(profile: Option<&str>) -> Target {
1674 Target::Agent { agent: fake_id(), profile: profile.map(str::to_owned) }
1675 }
1676
1677 fn run_fake(f: &Fixture, profile: Option<&str>) -> Report {
1679 let bin = bin_dir(f, true);
1680 run_with(f, &agent_target(profile), Some(bin.as_os_str()), &|_| false)
1681 }
1682
1683 fn profile_dir_of(f: &Fixture, profile: &str) -> PathBuf {
1684 f.root.join("profiles").join(profile).join("fake")
1685 }
1686
1687 fn skips_for_profile_checks(report: &Report) {
1688 for id in [CheckId::Profile, CheckId::ProfilePath, CheckId::LaunchEnvironment] {
1689 skipped(report, id);
1690 }
1691 }
1692
1693 #[test]
1694 fn bare_doctor_has_three_records_per_agent() {
1695 let f = fixture();
1696 let report = run_host(&f);
1697 let seen: Vec<(&str, CheckId)> = report
1698 .checks
1699 .iter()
1700 .filter(|check| {
1701 matches!(
1702 check.id,
1703 CheckId::Executable | CheckId::Support | CheckId::RepositoryLocalExecutable
1704 )
1705 })
1706 .map(|check| (check.agent.unwrap(), check.id))
1707 .collect();
1708 let expected: Vec<(&str, CheckId)> = adapter::known_agents()
1709 .into_iter()
1710 .flat_map(|id| [(id, CheckId::Executable), (id, CheckId::Support)])
1711 .collect();
1712 assert_eq!(seen, expected);
1713 }
1714
1715 #[test]
1716 fn not_installed_is_info_bare_and_fail_targeted() {
1717 let f = fixture();
1718 let bin = bin_dir(&f, false);
1719 let bare = run_with(&f, &Target::Host, Some(bin.as_os_str()), &|_| false);
1720 let fake = bare
1721 .checks
1722 .iter()
1723 .find(|check| check.id == CheckId::Executable && check.agent == Some("fake"))
1724 .unwrap();
1725 assert_eq!(fake.status, Status::Info);
1726 let targeted = run_with(&f, &agent_target(None), Some(bin.as_os_str()), &|_| false);
1727 assert_eq!(only(&targeted, CheckId::Executable).status, Status::Fail);
1728 assert!(targeted.failed());
1729 }
1730
1731 #[cfg(unix)]
1732 fn run_at_root(f: &Fixture, root: PathBuf) -> Report {
1733 let environment = env(Ok(AppRoot::from_path(root)), &f.base);
1734 run(&Target::Host, &environment)
1735 }
1736
1737 #[cfg(unix)]
1738 fn assert_root_blocked(report: &Report, root: &Path, ancestor: &Path, reason: &str) {
1739 let record = only(report, CheckId::AppRoot);
1740 assert_eq!(record.status, Status::Fail, "{record:?}");
1741 assert_eq!(record.subject, Some(shown(root)));
1742 assert_eq!(record.data, json!({ "path": shown(root), "exists": false }));
1743 assert!(record.message.contains(&ancestor.display().to_string()), "{}", record.message);
1744 assert!(record.message.contains(reason), "{}", record.message);
1745 for id in [CheckId::AppRootWritable, CheckId::Config, CheckId::ProfilesDir] {
1746 skipped(report, id);
1747 }
1748 assert!(report.failed());
1749 }
1750
1751 #[cfg(unix)]
1752 #[test]
1753 fn dangling_ancestor_of_root_fails() {
1754 let f = fixture();
1755 let broken = f.base.join("broken");
1756 std::os::unix::fs::symlink(f.base.join("gone"), &broken).unwrap();
1757 let root = broken.join("app");
1758 let report = run_at_root(&f, root.clone());
1759 assert_root_blocked(&report, &root, &broken, "dangling symbolic link");
1760 }
1761
1762 #[cfg(unix)]
1763 #[test]
1764 fn file_ancestor_of_root_fails() {
1765 let f = fixture();
1766 let file = f.base.join("plain");
1767 fs::write(&file, "x").unwrap();
1768 let root = file.join("app");
1769 let report = run_at_root(&f, root.clone());
1770 assert_root_blocked(&report, &root, &file, "not a directory");
1771 }
1772
1773 #[cfg(unix)]
1774 #[test]
1775 fn configured_executable_without_execute_bit_fails() {
1776 let f = fixture();
1777 fs::create_dir_all(&f.root).unwrap();
1778 let bin = bin_dir(&f, true);
1779 let plain = f.base.join("plain-agent");
1780 fs::write(&plain, "x").unwrap();
1781 set_mode(&plain, 0o644);
1782 fs::write(
1783 f.root.join("config.toml"),
1784 format!("[agents.fake]\nexecutable = '{}'\n", plain.display()),
1785 )
1786 .unwrap();
1787 let bare = run_with(&f, &Target::Host, Some(bin.as_os_str()), &|_| false);
1788 let fake = bare
1789 .checks
1790 .iter()
1791 .find(|check| check.id == CheckId::Executable && check.agent == Some("fake"))
1792 .unwrap();
1793 let targeted = run_with(&f, &agent_target(None), Some(bin.as_os_str()), &|_| false);
1794 for record in [fake, only(&targeted, CheckId::Executable)] {
1795 assert_eq!(record.status, Status::Fail, "{record:?}");
1796 assert_eq!(record.data["state"], json!("found"));
1797 assert!(record.message.contains("no execute permission bit"), "{}", record.message);
1798 assert!(record.message.contains(&plain.display().to_string()));
1799 }
1800 assert!(targeted.failed());
1801 }
1802
1803 #[test]
1804 fn configured_missing_and_shell_script_fail() {
1805 let f = fixture();
1806 fs::create_dir_all(&f.root).unwrap();
1807 let bin = bin_dir(&f, true);
1808 let nope = f.base.join("nope");
1809 fs::write(
1810 f.root.join("config.toml"),
1811 format!("[agents.fake]\nexecutable = '{}'\n", nope.display()),
1812 )
1813 .unwrap();
1814 let report = run_with(&f, &agent_target(None), Some(bin.as_os_str()), &|_| false);
1815 let found = only(&report, CheckId::Executable);
1816 assert_eq!(found.status, Status::Fail);
1817 assert_eq!(found.data["state"], json!("configured_missing"));
1818
1819 let script = f.base.join("fake.cmd");
1820 fs::write(&script, "@echo off").unwrap();
1821 fs::write(
1822 f.root.join("config.toml"),
1823 format!("[agents.fake]\nexecutable = '{}'\n", script.display()),
1824 )
1825 .unwrap();
1826 let report = run_with(&f, &agent_target(None), Some(bin.as_os_str()), &|_| false);
1827 let found = only(&report, CheckId::Executable);
1828 assert_eq!(found.status, Status::Fail);
1829 assert_eq!(found.data["state"], json!("unsupported"));
1830 }
1831
1832 #[cfg(unix)]
1833 #[test]
1834 fn repository_local_executable_warns() {
1835 let f = fixture();
1836 let local = f.base.join("bin");
1837 fs::create_dir_all(&local).unwrap();
1838 let exe = local.join("fake-agent");
1839 fs::write(&exe, "x").unwrap();
1840 set_mode(&exe, 0o755);
1841 let report = run_with(&f, &agent_target(None), Some(OsStr::new("bin")), &|_| false);
1842 let found = only(&report, CheckId::RepositoryLocalExecutable);
1843 assert_eq!(found.status, Status::Warn);
1844 assert_eq!(found.subject.as_deref(), Some(exe.to_str().unwrap()));
1845 let without = run_with(&f, &agent_target(None), None, &|_| false);
1846 assert!(find(&without, CheckId::RepositoryLocalExecutable).is_empty());
1847 }
1848
1849 #[test]
1850 fn support_never_probes() {
1851 let f = fixture();
1852 let report = run_fake(&f, None);
1853 let found = only(&report, CheckId::Support);
1854 assert_eq!(found.status, Status::Info);
1855 assert_eq!(found.data["installed_version"], Value::Null);
1856 assert_eq!(found.data["version_probe"], json!("not_run"));
1857 assert_eq!(found.data["capabilities"].as_array().unwrap().len(), 3);
1858 }
1859
1860 #[test]
1861 fn invalid_profile_argument_fails_resolution() {
1862 let f = fixture();
1863 let report = run_fake(&f, Some("bad/name"));
1864 assert_eq!(only(&report, CheckId::Resolution).status, Status::Fail);
1865 skips_for_profile_checks(&report);
1866 assert_eq!(report.profile, None);
1867 }
1868
1869 #[test]
1870 fn nothing_resolves_warns_and_skips() {
1871 let f = fixture();
1872 let report = run_fake(&f, None);
1873 assert_eq!(only(&report, CheckId::Resolution).status, Status::Warn);
1874 skips_for_profile_checks(&report);
1875 }
1876
1877 #[test]
1878 fn discovery_failure_skips_resolution_only_without_profile() {
1879 let f = fixture();
1880 let missing = f.base.join("start");
1881 let bin = bin_dir(&f, true);
1882 let run_at = |profile: Option<&str>| {
1883 let environment = Environment {
1884 root: Ok(AppRoot::from_path(f.root.clone())),
1885 path_var: Some(bin.as_os_str()),
1886 cwd: &f.base,
1887 start: &missing,
1888 is_set: &|_| false,
1889 };
1890 run(&agent_target(profile), &environment)
1891 };
1892 let without = run_at(None);
1893 skipped(&without, CheckId::Resolution);
1894 let with = run_at(Some("work"));
1895 assert_eq!(only(&with, CheckId::Resolution).status, Status::Info);
1896 }
1897
1898 #[test]
1899 fn absent_profile_is_lazy_info() {
1900 let f = fixture();
1901 fs::create_dir_all(f.root.join("profiles")).unwrap();
1902 let report = run_fake(&f, Some("work"));
1903 assert_eq!(only(&report, CheckId::Profile).status, Status::Info);
1904 assert_eq!(only(&report, CheckId::ProfilePath).status, Status::Info);
1905 assert!(!report.failed());
1906 assert_eq!(report.profile.as_ref().map(ProfileName::as_str), Some("work"));
1907 }
1908
1909 #[test]
1910 fn fresh_root_profile_path_is_info() {
1911 let f = fixture();
1912 assert!(!f.root.exists());
1913 let report = run_fake(&f, Some("work"));
1914 assert_eq!(only(&report, CheckId::ProfilePath).status, Status::Info);
1915 assert!(!report.failed());
1916 }
1917
1918 #[cfg(unix)]
1919 #[test]
1920 fn materialized_profile_passes_with_mode() {
1921 let f = fixture();
1922 let dir = profile_dir_of(&f, "work");
1923 fs::create_dir_all(&dir).unwrap();
1924 set_mode(&dir, 0o700);
1925 let report = run_fake(&f, Some("work"));
1926 assert_eq!(only(&report, CheckId::Profile).status, Status::Pass);
1927 let path = only(&report, CheckId::ProfilePath);
1928 assert_eq!(path.status, Status::Pass);
1929 assert_eq!(path.data["mode"], json!("700"));
1930 }
1931
1932 #[test]
1933 fn file_where_the_profile_dir_goes_fails() {
1934 let f = fixture();
1935 fs::create_dir_all(f.root.join("profiles")).unwrap();
1936 let work = f.root.join("profiles").join("work");
1937 fs::write(&work, "x").unwrap();
1938 let report = run_fake(&f, Some("work"));
1939 let path = only(&report, CheckId::ProfilePath);
1940 assert_eq!(path.status, Status::Fail);
1941 assert!(path.message.contains(work.to_str().unwrap()), "{}", path.message);
1942 }
1943
1944 #[test]
1945 fn wrong_kind_at_the_leaf_fails() {
1946 let f = fixture();
1947 let leaf = profile_dir_of(&f, "work");
1948 fs::create_dir_all(leaf.parent().unwrap()).unwrap();
1949 fs::write(&leaf, "x").unwrap();
1950 let report = run_fake(&f, Some("work"));
1951 assert_eq!(only(&report, CheckId::ProfilePath).status, Status::Fail);
1952 }
1953
1954 #[cfg(unix)]
1955 #[test]
1956 fn symlinked_profile_dir_is_not_a_fail() {
1957 let f = fixture();
1958 fs::create_dir_all(f.root.join("profiles")).unwrap();
1959 let real = f.base.join("real");
1960 fs::create_dir_all(&real).unwrap();
1961 std::os::unix::fs::symlink(&real, f.root.join("profiles").join("work")).unwrap();
1962 let report = run_fake(&f, Some("work"));
1963 let status = only(&report, CheckId::ProfilePath).status;
1964 assert!(matches!(status, Status::Info | Status::Pass), "{status:?}");
1965 }
1966
1967 #[cfg(unix)]
1968 #[test]
1969 fn dangling_symlink_fails() {
1970 let f = fixture();
1971 fs::create_dir_all(f.root.join("profiles")).unwrap();
1972 std::os::unix::fs::symlink(f.base.join("nowhere"), f.root.join("profiles").join("work"))
1973 .unwrap();
1974 let report = run_fake(&f, Some("work"));
1975 let path = only(&report, CheckId::ProfilePath);
1976 assert_eq!(path.status, Status::Fail);
1977 assert!(path.message.contains("dangling"), "{}", path.message);
1978 }
1979
1980 #[cfg(unix)]
1981 #[test]
1982 fn read_only_existing_leaf_warns() {
1983 let f = fixture();
1984 let dir = profile_dir_of(&f, "work");
1985 fs::create_dir_all(&dir).unwrap();
1986 set_mode(&dir, 0o555);
1987 let report = run_fake(&f, Some("work"));
1988 set_mode(&dir, 0o755);
1989 assert_eq!(only(&report, CheckId::ProfilePath).status, Status::Warn);
1990 }
1991
1992 #[cfg(unix)]
1993 #[test]
1994 fn read_only_ancestor_fails() {
1995 let f = fixture();
1996 let profiles = f.root.join("profiles");
1997 fs::create_dir_all(&profiles).unwrap();
1998 set_mode(&profiles, 0o555);
1999 let report = run_fake(&f, Some("work"));
2000 set_mode(&profiles, 0o755);
2001 assert_eq!(only(&report, CheckId::ProfilePath).status, Status::Fail);
2002 }
2003
2004 #[test]
2005 fn launch_environment_reports_without_values() {
2006 let f = fixture();
2007 let bin = bin_dir(&f, true);
2008 let report = run_with(&f, &agent_target(Some("work")), Some(bin.as_os_str()), &|name| {
2009 name == "FAKE_AGENT_HOME"
2010 });
2011 let found = only(&report, CheckId::LaunchEnvironment);
2012 assert_eq!(found.status, Status::Info);
2013 assert_eq!(found.data["kind"], json!("env"));
2014 assert_eq!(found.data["name"], json!("FAKE_AGENT_HOME"));
2015 assert_eq!(found.data["already_set"], json!(true));
2016 assert_eq!(found.data["value"], json!(profile_dir_of(&f, "work").display().to_string()));
2017 assert!(found.message.contains("already set"), "{}", found.message);
2018 }
2019
2020 #[test]
2021 fn launch_environment_for_every_adapter() {
2022 for id in adapter::known_agents() {
2023 let f = fixture();
2024 let target = Target::Agent {
2025 agent: AgentId::parse(id).unwrap(),
2026 profile: Some("work".to_owned()),
2027 };
2028 let report = run_with(&f, &target, None, &|_| false);
2029 let found = only(&report, CheckId::LaunchEnvironment);
2030 let kind = found.data["kind"].as_str().unwrap_or_else(|| panic!("{id}: no kind"));
2031 assert!(["env", "env_file", "flag_dir", "flag_file"].contains(&kind), "{id}: {kind}");
2032 let flag = kind == "flag_dir" || kind == "flag_file";
2033 assert_eq!(found.data["already_set"].is_null(), flag, "{id}: {kind}");
2034 if !flag {
2035 assert!(found.data["already_set"].is_boolean(), "{id}");
2036 }
2037 }
2038 }
2039
2040 #[test]
2041 fn config_failure_skips_executable_and_resolution() {
2042 let f = fixture();
2043 fs::create_dir_all(&f.root).unwrap();
2044 fs::write(f.root.join("config.toml"), "x =").unwrap();
2045 let report = run_fake(&f, None);
2046 skipped(&report, CheckId::Executable);
2047 skipped(&report, CheckId::Resolution);
2048 let bare = run_host(&f);
2049 let executables: Vec<&Check> = find(&bare, CheckId::Executable);
2050 assert_eq!(executables.len(), adapter::known_agents().len());
2051 assert!(executables.iter().all(|check| check.status == Status::Skip));
2052 }
2053
2054 #[test]
2055 fn ambient_credential_names_each_set_variable_and_only_those() {
2056 let f = fixture();
2057 let codex = Target::Agent { agent: AgentId::parse("codex").unwrap(), profile: None };
2058 let subjects = |is_set: &dyn Fn(&str) -> bool| -> Vec<String> {
2059 find(&run_with(&f, &codex, None, is_set), CheckId::AmbientCredential)
2060 .iter()
2061 .map(|check| check.subject.clone().expect("a subject"))
2062 .collect()
2063 };
2064 assert_eq!(
2066 subjects(&|name| name == "CODEX_ACCESS_TOKEN" || name == "OPENAI_API_KEY"),
2067 ["OPENAI_API_KEY", "CODEX_ACCESS_TOKEN"]
2068 );
2069 assert!(subjects(&|_| false).is_empty());
2070 assert!(subjects(&|name| name == "CURSOR_API_KEY").is_empty());
2072
2073 let report = run_with(&f, &codex, None, &|name| name == "CODEX_API_KEY");
2074 let record = only(&report, CheckId::AmbientCredential);
2075 assert_eq!(record.status, Status::Info);
2076 assert_eq!(record.data, json!({ "name": "CODEX_API_KEY" }));
2077 assert_eq!(
2078 record.message,
2079 "CODEX_API_KEY is set in this environment; codex may use it instead of the profile's own credentials"
2080 );
2081 let quiet = run_with(&f, &codex, None, &|_| false);
2083 assert_eq!(report.failed(), quiet.failed());
2084 assert_eq!(report.count(Status::Warn), quiet.count(Status::Warn));
2085
2086 let report = run_with(&f, &agent_target(Some("work")), None, &|_| true);
2088 assert!(find(&report, CheckId::AmbientCredential).is_empty());
2089 }
2090
2091 #[test]
2092 fn conflicting_option_and_capabilities() {
2093 let f = fixture();
2094 let report = run_fake(&f, Some("work"));
2095 let options = find(&report, CheckId::ConflictingOption);
2096 assert_eq!(options.len(), 1);
2097 assert_eq!(options[0].subject.as_deref(), Some("--fake-profile"));
2098 let capabilities = find(&report, CheckId::Capability);
2099 assert_eq!(capabilities.len(), 3);
2100 assert!(capabilities.iter().all(|check| check.status == Status::Info));
2101 for check in &capabilities {
2102 let label = Capability::ALL
2103 .into_iter()
2104 .find(|capability| {
2105 Some(json::capability_token(*capability)) == check.subject.as_deref()
2106 })
2107 .map(output::capability_label)
2108 .expect("the subject is a capability token");
2109 assert!(
2110 check.message.starts_with(&format!("{label} isolation is unknown: ")),
2111 "{}",
2112 check.message
2113 );
2114 }
2115 }
2116
2117 #[test]
2118 fn flag_adapter_lists_its_own_flag() {
2119 let f = fixture();
2120 let target = Target::Agent { agent: AgentId::parse("aider").unwrap(), profile: None };
2121 let report = run_with(&f, &target, None, &|_| false);
2122 let subjects: Vec<&str> = find(&report, CheckId::ConflictingOption)
2123 .iter()
2124 .map(|check| check.subject.as_deref().unwrap())
2125 .collect();
2126 assert!(subjects.contains(&"--config"), "{subjects:?}");
2127 }
2128
2129 #[test]
2130 fn case_twin_profile_fails() {
2131 let f = fixture();
2132 fs::create_dir_all(&f.root).unwrap();
2133 if !case_sensitive(&f.root) {
2134 println!("skipped: the filesystem is case-insensitive");
2135 return;
2136 }
2137 fs::create_dir_all(f.root.join("profiles").join("Work")).unwrap();
2138 let report = run_fake(&f, Some("work"));
2139 let found = only(&report, CheckId::Profile);
2140 assert_eq!(found.status, Status::Fail);
2141 assert!(found.message.contains("Work"), "{}", found.message);
2142 }
2143
2144 #[test]
2145 fn targeted_doctor_skips_profile_checks_when_profiles_dir_fails() {
2146 let f = fixture();
2147 fs::create_dir_all(&f.root).unwrap();
2148 fs::write(f.root.join("profiles"), "x").unwrap();
2149 let report = run_fake(&f, Some("work"));
2150 assert_eq!(only(&report, CheckId::ProfilesDir).status, Status::Fail);
2151 for id in [CheckId::Profile, CheckId::ProfilePath] {
2152 let found = only(&report, id);
2153 assert_eq!(found.status, Status::Skip, "{id:?}");
2154 assert_eq!(found.message, format!("{} skipped: profiles_dir failed", id.token()));
2155 }
2156 assert_ne!(only(&report, CheckId::LaunchEnvironment).status, Status::Skip);
2157 }
2158
2159 #[test]
2160 fn configured_directory_executable_fails() {
2161 let f = fixture();
2162 fs::create_dir_all(&f.root).unwrap();
2163 let bin = bin_dir(&f, true);
2164 let dir = f.base.join("a-directory");
2165 fs::create_dir_all(&dir).unwrap();
2166 fs::write(
2167 f.root.join("config.toml"),
2168 format!("[agents.fake]\nexecutable = '{}'\n", dir.display()),
2169 )
2170 .unwrap();
2171 let report = run_with(&f, &agent_target(None), Some(bin.as_os_str()), &|_| false);
2172 let found = only(&report, CheckId::Executable);
2173 assert_eq!(found.status, Status::Fail);
2174 assert_eq!(found.data["state"], json!("configured_missing"));
2175 }
2176}