Skip to main content

agent_profile/
error.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//! Structured errors and their exit codes (spec §33).
8
9use std::fmt;
10use std::io;
11use std::path::{Path, PathBuf};
12
13use crate::name::InvalidReason;
14
15/// Why an agent's executable was not found (spec §20).
16#[derive(Debug)]
17pub enum NotInstalledReason {
18    /// No absolute `PATH` entry holds it; `ignored_relative` relative entries were skipped.
19    NotOnPath {
20        ignored_relative: usize,
21    },
22    ExplicitMissing(PathBuf),
23}
24
25/// Every error `agent-profile` reports. `exit_code` maps each variant to spec §33.
26#[derive(Debug, thiserror::Error)]
27pub enum Error {
28    #[error("{message}")]
29    Usage { message: String },
30
31    #[error("`{command}` is not yet implemented")]
32    NotYetImplemented { command: String },
33
34    #[error("{}", unknown_agent_message(agent, known, unknown_configured))]
35    UnknownAgent { agent: String, known: Vec<String>, unknown_configured: Vec<String> },
36
37    #[error(
38        "`{option}` conflicts with how agent-profile selects the {agent} profile ({mechanism}); remove it from \
39         the agent arguments"
40    )]
41    ArgumentConflict { agent: String, option: String, mechanism: String },
42
43    #[error("{}", not_installed_message(agent, reason, unknown_configured))]
44    AgentNotInstalled { agent: String, reason: NotInstalledReason, unknown_configured: Vec<String> },
45
46    #[error("invalid profile name {name:?}: {reason}")]
47    InvalidProfileName { name: String, reason: InvalidReason },
48
49    #[error("{}", no_profile_message(agent, config_file, *in_repository))]
50    NoProfile { agent: String, config_file: PathBuf, in_repository: bool },
51
52    #[error("repository {}: {reason}", path.display())]
53    Repository { path: PathBuf, reason: String },
54
55    #[error("{message}")]
56    AppRoot { message: String },
57
58    #[error("{}", config_invalid_message(path, key.as_deref(), detail))]
59    ConfigInvalid { path: PathBuf, key: Option<String>, detail: String },
60
61    #[error("could not write {}: {source}", path.display())]
62    ConfigWrite { path: PathBuf, source: io::Error },
63
64    #[error("profile path {}: {source}", path.display())]
65    ProfileDir { path: PathBuf, source: io::Error },
66
67    #[error("{}", profile_referenced_message(agent, profile, references))]
68    ProfileReferenced { agent: String, profile: String, references: Vec<String> },
69
70    #[error(
71        "profile `{requested}` differs only in letter case from the existing profile entry `{existing}`; \
72         use `{existing}` or choose a different name"
73    )]
74    ProfileCaseConflict { requested: String, existing: String },
75
76    #[error("{}", unsupported_message(agent, path, config_file))]
77    UnsupportedExecutable { agent: String, path: PathBuf, config_file: PathBuf },
78
79    #[error("could not launch {}: {source}", executable.display())]
80    Launch { executable: PathBuf, source: io::Error },
81
82    #[error("{context}: {source}")]
83    Io { context: String, source: io::Error },
84}
85
86impl Error {
87    /// The spec §33 exit code for this error.
88    pub fn exit_code(&self) -> i32 {
89        match self {
90            Error::Usage { .. }
91            | Error::NotYetImplemented { .. }
92            | Error::UnknownAgent { .. }
93            | Error::ArgumentConflict { .. } => 2,
94            Error::AgentNotInstalled { .. } => 3,
95            Error::InvalidProfileName { .. }
96            | Error::NoProfile { .. }
97            | Error::AppRoot { .. }
98            | Error::ConfigInvalid { .. }
99            | Error::ConfigWrite { .. }
100            | Error::ProfileDir { .. }
101            | Error::ProfileCaseConflict { .. }
102            | Error::ProfileReferenced { .. }
103            | Error::Repository { .. } => 4,
104            Error::UnsupportedExecutable { .. } | Error::Launch { .. } => 6,
105            Error::Io { .. } => 1,
106        }
107    }
108}
109
110pub type Result<T> = std::result::Result<T, Error>;
111
112fn unknown_configured_suffix(unknown_configured: &[String]) -> String {
113    if unknown_configured.is_empty() {
114        String::new()
115    } else {
116        format!("; config.toml also configures unknown agents: {}", backticked(unknown_configured))
117    }
118}
119
120fn backticked(items: &[String]) -> String {
121    items.iter().map(|item| format!("`{item}`")).collect::<Vec<_>>().join(", ")
122}
123
124fn unknown_agent_message(agent: &str, known: &[String], unknown_configured: &[String]) -> String {
125    let known = if known.is_empty() {
126        "no agents are available in this build".to_owned()
127    } else {
128        format!("known agents: {}", backticked(known))
129    };
130    format!("unknown agent `{agent}` ({known}){}", unknown_configured_suffix(unknown_configured))
131}
132
133fn not_installed_message(
134    agent: &str,
135    reason: &NotInstalledReason,
136    unknown_configured: &[String],
137) -> String {
138    format!("`{agent}` is not installed: {reason}{}", unknown_configured_suffix(unknown_configured))
139}
140
141fn no_profile_message(agent: &str, config_file: &Path, in_repository: bool) -> String {
142    let link =
143        if in_repository { "link this repository (agent-profile link <profile>), " } else { "" };
144    format!(
145        "no profile selected for `{agent}`; name one (agent-profile {agent} <profile>), {link}or set \
146         default_profile in {}",
147        config_file.display()
148    )
149}
150
151/// The suffix when the block includes the global default: config.toml is hand-edited, so no command
152/// rewrites it (SP3 design D3). The dash is ASCII, never an em dash (the repo has hit non-ASCII
153/// corruption in user-visible text before).
154fn profile_referenced_suffix() -> &'static str {
155    " - default_profile is set in config.toml; edit it to change the default"
156}
157
158fn profile_referenced_message(agent: &str, profile: &str, references: &[String]) -> String {
159    let mut message = format!(
160        "cannot delete profile '{profile}' for {agent}: it is referenced by one or more repository \
161         mappings ({}); unlink the profile first, then retry deletion",
162        references.join(", ")
163    );
164    if references.iter().any(|reference| reference == "default_profile") {
165        message.push_str(profile_referenced_suffix());
166    }
167    message
168}
169
170fn unsupported_message(agent: &str, path: &Path, config_file: &Path) -> String {
171    format!(
172        "`{agent}` resolves to {}, which agent-profile cannot launch without a shell. Install the agent's native \
173         executable (for example the vendor's standalone installer) or set [agents.{agent}] executable = \
174         \"<absolute path to a native .exe>\" in {}",
175        path.display(),
176        config_file.display()
177    )
178}
179
180fn config_invalid_message(path: &Path, key: Option<&str>, detail: &str) -> String {
181    let key = key.map(|key| format!(" (key `{key}`)")).unwrap_or_default();
182    format!(
183        "invalid configuration {}{key}: {detail}. agent-profile never rewrites an invalid \
184         configuration; fix or move the file.",
185        path.display()
186    )
187}
188
189impl fmt::Display for NotInstalledReason {
190    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191        match self {
192            NotInstalledReason::NotOnPath { ignored_relative: 0 } => write!(f, "not found in PATH"),
193            NotInstalledReason::NotOnPath { ignored_relative } => {
194                write!(
195                    f,
196                    "not found in PATH ({ignored_relative} relative PATH entries were ignored)"
197                )
198            }
199            NotInstalledReason::ExplicitMissing(path) => write!(
200                f,
201                "the configured executable {} does not exist or is not a file",
202                path.display()
203            ),
204        }
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    fn io() -> io::Error {
213        io::Error::other("x")
214    }
215
216    #[test]
217    fn exit_codes_follow_spec_33() {
218        let cases: Vec<(Error, i32)> = vec![
219            (Error::Usage { message: "m".into() }, 2),
220            (Error::NotYetImplemented { command: "doctor".into() }, 2),
221            (
222                Error::UnknownAgent {
223                    agent: "a".into(),
224                    known: vec![],
225                    unknown_configured: vec![],
226                },
227                2,
228            ),
229            (
230                Error::ArgumentConflict {
231                    agent: "aider".into(),
232                    option: "--conf".into(),
233                    mechanism: "argument --config <file>".into(),
234                },
235                2,
236            ),
237            (
238                Error::AgentNotInstalled {
239                    agent: "a".into(),
240                    reason: NotInstalledReason::NotOnPath { ignored_relative: 0 },
241                    unknown_configured: vec![],
242                },
243                3,
244            ),
245            (Error::InvalidProfileName { name: "".into(), reason: InvalidReason::Empty }, 4),
246            (
247                Error::NoProfile {
248                    agent: "a".into(),
249                    config_file: "c".into(),
250                    in_repository: true,
251                },
252                4,
253            ),
254            (Error::Repository { path: "r".into(), reason: "not a directory".into() }, 4),
255            (Error::AppRoot { message: "m".into() }, 4),
256            (Error::ConfigInvalid { path: "c".into(), key: None, detail: "d".into() }, 4),
257            (Error::ConfigWrite { path: "c".into(), source: io() }, 4),
258            (Error::ProfileDir { path: "p".into(), source: io() }, 4),
259            (Error::ProfileCaseConflict { requested: "WORK".into(), existing: "work".into() }, 4),
260            (
261                Error::ProfileReferenced {
262                    agent: "a".into(),
263                    profile: "p".into(),
264                    references: vec![],
265                },
266                4,
267            ),
268            (
269                Error::UnsupportedExecutable {
270                    agent: "a".into(),
271                    path: "a.cmd".into(),
272                    config_file: "c".into(),
273                },
274                6,
275            ),
276            (Error::Launch { executable: "a".into(), source: io() }, 6),
277            (Error::Io { context: "c".into(), source: io() }, 1),
278        ];
279        for (error, code) in cases {
280            assert_eq!(error.exit_code(), code, "{error:?}");
281        }
282    }
283
284    #[test]
285    fn unknown_agent_message_lists_known_and_unknown_configured() {
286        let none =
287            Error::UnknownAgent { agent: "zzz".into(), known: vec![], unknown_configured: vec![] };
288        assert_eq!(none.to_string(), "unknown agent `zzz` (no agents are available in this build)");
289        let some = Error::UnknownAgent {
290            agent: "fakr".into(),
291            known: vec!["fake".into()],
292            unknown_configured: vec!["fakr".into()],
293        };
294        assert_eq!(
295            some.to_string(),
296            "unknown agent `fakr` (known agents: `fake`); config.toml also configures unknown agents: `fakr`"
297        );
298    }
299
300    #[test]
301    fn config_invalid_message_names_file_key_and_guidance() {
302        let error = Error::ConfigInvalid {
303            path: "/r/config.toml".into(),
304            key: Some("agents.fake.path".into()),
305            detail: "unknown field".into(),
306        };
307        let message = error.to_string();
308        assert!(message.contains("/r/config.toml"), "{message}");
309        assert!(message.contains("`agents.fake.path`"), "{message}");
310        assert!(message.contains("never rewrites an invalid configuration"), "{message}");
311    }
312
313    #[test]
314    fn not_installed_messages_share_one_text_and_count_ignored_entries() {
315        let error = |reason| Error::AgentNotInstalled {
316            agent: "codex".into(),
317            reason,
318            unknown_configured: vec![],
319        };
320        assert_eq!(
321            error(NotInstalledReason::NotOnPath { ignored_relative: 0 }).to_string(),
322            "`codex` is not installed: not found in PATH"
323        );
324        assert_eq!(
325            error(NotInstalledReason::NotOnPath { ignored_relative: 2 }).to_string(),
326            "`codex` is not installed: not found in PATH (2 relative PATH entries were ignored)"
327        );
328        let missing = NotInstalledReason::ExplicitMissing("/x/codex".into());
329        assert_eq!(
330            missing.to_string(),
331            format!(
332                "the configured executable {} does not exist or is not a file",
333                Path::new("/x/codex").display()
334            )
335        );
336    }
337
338    #[test]
339    fn conflict_and_unsupported_messages() {
340        let conflict = Error::ArgumentConflict {
341            agent: "aider".into(),
342            option: "--conf".into(),
343            mechanism: "argument --config <file>".into(),
344        };
345        assert_eq!(
346            conflict.to_string(),
347            "`--conf` conflicts with how agent-profile selects the aider profile (argument --config <file>); \
348             remove it from the agent arguments"
349        );
350        let unsupported = Error::UnsupportedExecutable {
351            agent: "codex".into(),
352            path: "codex.cmd".into(),
353            config_file: "config.toml".into(),
354        };
355        assert_eq!(
356            unsupported.to_string(),
357            "`codex` resolves to codex.cmd, which agent-profile cannot launch without a shell. Install the \
358             agent's native executable (for example the vendor's standalone installer) or set \
359             [agents.codex] executable = \"<absolute path to a native .exe>\" in config.toml"
360        );
361    }
362
363    #[test]
364    fn no_profile_message_names_every_way_to_select_a_profile() {
365        let error = |in_repository| Error::NoProfile {
366            agent: "claude".into(),
367            config_file: "config.toml".into(),
368            in_repository,
369        };
370        assert_eq!(
371            error(true).to_string(),
372            "no profile selected for `claude`; name one (agent-profile claude <profile>), link this \
373             repository (agent-profile link <profile>), or set default_profile in config.toml"
374        );
375        assert_eq!(
376            error(false).to_string(),
377            "no profile selected for `claude`; name one (agent-profile claude <profile>), or set \
378             default_profile in config.toml"
379        );
380    }
381
382    #[test]
383    fn repository_message_names_the_path_and_the_reason() {
384        let error =
385            Error::Repository { path: "repo/.git".into(), reason: "not a directory".into() };
386        assert_eq!(
387            error.to_string(),
388            format!("repository {}: not a directory", Path::new("repo/.git").display())
389        );
390    }
391
392    #[test]
393    fn profile_path_message_fits_files_and_directories() {
394        let error = Error::ProfileDir {
395            path: ".aider.conf.yml".into(),
396            source: io::Error::other("denied"),
397        };
398        assert_eq!(error.to_string(), "profile path .aider.conf.yml: denied");
399    }
400}