1use std::fmt;
10use std::io;
11use std::path::{Path, PathBuf};
12
13use crate::name::InvalidReason;
14
15#[derive(Debug)]
17pub enum NotInstalledReason {
18 NotOnPath {
20 ignored_relative: usize,
21 },
22 ExplicitMissing(PathBuf),
23}
24
25#[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 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
151fn 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}