Skip to main content

agent_profile/
doctor.rs

1// Copyright (c) 2026 Costas Kirgoussios
2//
3// This software is licensed under the PolyForm Noncommercial License 1.0.0.
4// You may obtain a copy of the License at:
5// https://polyformproject.org/licenses/noncommercial/1.0.0/
6
7//! `doctor`: the check model and the host and repository checks (SP5c design §4, §5.1, §5.2, §6).
8//!
9//! Every check is read-only. A check that cannot run because one it depends on failed emits exactly one
10//! `skip` record naming that check (design §4).
11
12use 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/// The outcome of one check (design §4).
35#[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/// The closed set of check identifiers (design §5).
57#[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/// One check record.
111#[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    /// `Value::Null` when the id has no data.
119    pub data: Value,
120}
121
122/// What a run reads from the process.
123pub struct Environment<'a> {
124    /// `AppRoot::resolve()`, its error rendered with `Display`.
125    pub root: std::result::Result<AppRoot, String>,
126    pub path_var: Option<&'a OsStr>,
127    /// The process's current directory.
128    pub cwd: &'a Path,
129    /// The current directory, or `cwd.join(--repo)`.
130    pub start: &'a Path,
131    /// Whether an environment variable is set.
132    pub is_set: &'a dyn Fn(&str) -> bool,
133}
134
135/// What to examine.
136pub enum Target {
137    Host,
138    Agent { agent: AgentId, profile: Option<String> },
139}
140
141/// The ordered records of a run, and the facts the JSON envelope needs.
142pub struct Report {
143    pub agent: Option<AgentId>,
144    /// The profile the agent form examined.
145    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
160/// Runs every check in scope, in the contract's order: host checks, then repository checks.
161pub 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
206/// What the host checks established, for the checks that depend on them.
207struct Host {
208    root: Option<AppRoot>,
209    config: Option<Config>,
210    /// `profiles_dir` failed on the profiles directory itself.
211    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
238/// The nearest ancestor of `path` (or `path` itself) that exists, following symbolic links. A dangling
239/// link, a missing entry and an entry below a non-directory all count as not existing; any other error
240/// stops the walk at the path that raised it.
241fn 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/// Why creating a missing path would fail below an existing ancestor.
259#[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
274/// The entry that stops a launch creating the missing `path`: a dangling symbolic link between `path` and
275/// its nearest existing ancestor, or that ancestor when it is not a directory. Nothing else blocks here.
276fn 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
358/// Pushes the `app_root` record and says whether the root can be used.
359fn 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
473/// Pushes `config`, `config_agent` and returns the configuration when it loaded.
474fn 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
531/// What the `profiles_dir` check established for the checks that depend on it.
532enum ProfilesDir {
533    Present,
534    Absent,
535    /// The failure names the profiles directory itself (design §4).
536    Failed,
537}
538
539/// Pushes the `profiles_dir` record.
540fn 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
619/// The first profile directory, in byte order of its name, that `list_profiles` would descend into and
620/// whose `read_dir` fails.
621fn 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
638/// One `fail` per pair of `profiles/` entries whose UTF-8 names differ only in ASCII case.
639fn 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
665/// Pushes the repository checks and returns the discovery, or `None` when it failed.
666fn 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
770/// The failed check that makes `mapping` and `mapping_agent` skip.
771fn mapping_dependency(host: &Host) -> CheckId {
772    if host.root.is_none() { CheckId::AppRoot } else { CheckId::Config }
773}
774
775/// A per-agent record.
776fn 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
791/// Pushes `executable`, then `repository_local_executable` when found (design §5.3).
792fn 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
890/// The `support` record: the evidence, never a probe of the installed agent.
891fn 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
919/// Pushes `resolution` and returns the profile it settled on.
920fn 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
985/// Pushes `profile`, `profile_path` and `launch_environment`, or one `skip` each.
986fn 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/// The octal permission bits on Unix, `null` elsewhere.
1052#[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
1115/// What a launch would meet creating `path`, which does not exist.
1116fn 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
1156/// The single `launch_environment` record: how the profile is passed, never a value read from the
1157/// environment.
1158fn 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
1200/// Pushes `conflicting_option`, `ambient_credential` and `capability` records.
1201fn 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    // Name only: `is_set` answers a bool, so this record cannot carry a value, a length or a hash.
1214    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    /// A tempdir holding an application root directory `root` (not yet created) and a start directory.
1276    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        // The executable must be absolute on this host, or the config is invalid and the check is skipped.
1420        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    /// Whether the filesystem under `dir` tells `a` and `A` apart.
1470    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    // ---- Task 3: the per-agent checks -------------------------------------------------------------
1639
1640    fn fake_id() -> AgentId {
1641        AgentId::parse("fake").unwrap()
1642    }
1643
1644    /// A directory under the fixture holding an "installed" fake agent (or nothing).
1645    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    /// `fake <profile> doctor` with the agent installed.
1678    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        // Declared order, not environment order, and only the variables that are set.
2065        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        // A variable another agent documents is not codex's to report.
2071        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        // Naming it never changes the verdict: the same run with nothing set fails or passes identically.
2082        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        // An adapter with no recorded variables reports nothing however much is set.
2087        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}