1use anyhow::Error;
2use auths_sdk::domains::signing::service::{ArtifactSigningError, SigningError};
3use auths_sdk::error::CoreTrustError;
4use auths_sdk::error::IdDriverStorageError;
5use auths_sdk::error::IdStorageError;
6use auths_sdk::error::PairingError;
7use auths_sdk::error::{AgentError, AuthsErrorInfo};
8use auths_sdk::error::{
9 ApprovalError, DeviceError, DeviceExtensionError, McpAuthError, OrgError, RegistrationError,
10 RotationError, SdkStorageError, SetupError, TrustError,
11};
12use auths_sdk::error::{FreezeError, InitError};
13use auths_sdk::workflows::auth::AuthChallengeError;
14use auths_verifier::{AttestationError, CommitVerificationError};
15use colored::Colorize;
16
17use crate::errors::cli_error::CliError;
18use crate::ux::format::Output;
19
20const DOCS_BASE_URL: &str = "https://docs.auths.dev";
21
22pub fn render_error(err: &Error, json_mode: bool) {
28 if json_mode {
29 render_json(err);
30 } else {
31 render_text(err);
32 }
33}
34
35fn extract_error_info(err: &Error) -> Option<(&str, &str, Option<&str>)> {
39 macro_rules! try_downcast {
40 ($source:expr, $($ty:ty),+ $(,)?) => {
41 $(
42 if let Some(e) = $source.downcast_ref::<$ty>() {
43 let code = AuthsErrorInfo::error_code(e);
44 let msg = format!("{e}");
45 let msg: &str = Box::leak(msg.into_boxed_str());
49 return Some((code, msg, AuthsErrorInfo::suggestion(e)));
50 }
51 )+
52 };
53 }
54
55 for cause in err.chain() {
56 try_downcast!(
57 cause,
58 AgentError,
59 AttestationError,
60 SetupError,
61 DeviceError,
62 DeviceExtensionError,
63 RotationError,
64 RegistrationError,
65 McpAuthError,
66 OrgError,
67 ApprovalError,
68 ArtifactSigningError,
69 SigningError,
70 SdkStorageError,
71 TrustError,
72 AuthChallengeError,
73 CommitVerificationError,
74 PairingError,
75 FreezeError,
76 InitError,
77 CoreTrustError,
78 IdStorageError,
79 IdDriverStorageError,
80 );
81 }
82
83 None
84}
85
86fn render_text(err: &Error) {
87 let out = Output::new();
88
89 if let Some(cli_err) = err.downcast_ref::<CliError>() {
90 eprintln!("\n{} {}", "Error:".red().bold(), cli_err);
91 eprintln!("\n{}", cli_err.suggestion());
92 if let Some(url) = cli_err.docs_url() {
93 eprintln!("Docs: {}", url);
94 }
95 eprintln!();
96 return;
97 }
98
99 if let Some((code, message, suggestion)) = extract_error_info(err) {
100 let prefix = format!("[{code}]").yellow();
101 out.print_error(&format!("{prefix} {}", out.bold(message)));
102 eprintln!();
103 if let Some(suggestion) = suggestion {
104 eprintln!(" fix: {}", suggestion.blue());
105 }
106 if code.starts_with("AUTHS-E") {
109 eprintln!(" look up: auths error show {code}");
110 }
111 return;
112 }
113
114 let msg = err.to_string();
116 let suggestion = match msg.as_str() {
117 s if s.contains("No identity found") => Some(format!(
118 "Run `auths init` to create one, or `auths key import` to restore from a backup.\n See: {DOCS_BASE_URL}/getting-started/quickstart/"
119 )),
120 s if s.contains("keychain") || s.contains("Secret Service") => Some(format!(
121 "Cannot access system keychain.\n\n If running headless (CI/Docker), set:\n export AUTHS_KEYCHAIN_BACKEND=file\n export AUTHS_PASSPHRASE=<your-passphrase>\n\n See: {DOCS_BASE_URL}/cli/troubleshooting/"
122 )),
123 s if s.contains("ssh-keygen") && s.contains("not found") => Some(
124 "ssh-keygen not found on PATH.\n\n Install OpenSSH:\n Ubuntu: sudo apt install openssh-client\n macOS: ssh-keygen is pre-installed\n Windows: Install OpenSSH via Settings > Apps > Optional features".to_string()
125 ),
126 s if s.contains("not a git repository") => Some(
127 "This command must be run inside a Git repository.\nRun `git init` first, or navigate to an existing repo.".to_string()
128 ),
129 s if s.contains("permission denied") || s.contains("Permission denied") => Some(format!(
130 "Permission denied. Check file permissions on the relevant path.\n Run `auths doctor` for a full health check.\n See: {DOCS_BASE_URL}/cli/troubleshooting/"
131 )),
132 s if s.contains("connection refused") || s.contains("timed out") || s.contains("timeout") => Some(
133 "Network connection failed. Check your internet connection and try again.\nIf using a registry, verify the URL with `auths config show`.".to_string()
134 ),
135 _ => None,
136 };
137
138 if let Some(suggestion) = suggestion {
139 out.print_error(&msg);
140 eprintln!("\n{suggestion}");
141 } else {
142 out.print_error(&format!("{err}"));
143 for cause in err.chain().skip(1) {
144 eprintln!(" caused by: {cause}");
145 }
146 eprintln!(
147 "\nhint: Run 'auths doctor' to check your setup, or 'auths error list' to browse known issues."
148 );
149 }
150}
151
152fn render_json(err: &Error) {
153 let json = if let Some(cli_err) = err.downcast_ref::<CliError>() {
154 build_json(
155 None,
156 &format!("{cli_err}"),
157 Some(cli_err.suggestion()),
158 cli_err.docs_url().map(|s| s.to_string()),
159 None,
160 )
161 } else if let Some((code, message, suggestion)) = extract_error_info(err) {
162 build_json(
165 Some(code),
166 message,
167 suggestion,
168 docs_url(code),
169 lookup(code),
170 )
171 } else {
172 build_json(None, &format!("{err}"), None, None, None)
173 };
174
175 eprintln!("{json}");
176}
177
178fn build_json(
179 code: Option<&str>,
180 message: &str,
181 suggestion: Option<&str>,
182 docs: Option<String>,
183 lookup: Option<String>,
184) -> String {
185 let mut map = serde_json::Map::new();
186 if let Some(c) = code {
187 map.insert("code".into(), serde_json::Value::String(c.into()));
188 }
189 map.insert("message".into(), serde_json::Value::String(message.into()));
190 if let Some(s) = suggestion {
191 map.insert("suggestion".into(), serde_json::Value::String(s.into()));
192 }
193 if let Some(l) = lookup {
194 map.insert("lookup".into(), serde_json::Value::String(l));
195 }
196 if let Some(d) = docs {
197 map.insert("docs".into(), serde_json::Value::String(d));
198 }
199
200 let wrapper = serde_json::json!({ "error": map });
201 serde_json::to_string_pretty(&wrapper)
202 .unwrap_or_else(|_| format!("{{\"error\":{{\"message\":\"{message}\"}}}}"))
203}
204
205fn lookup(code: &str) -> Option<String> {
207 if code.starts_with("AUTHS-E") {
208 Some(format!("auths error show {code}"))
209 } else {
210 None
211 }
212}
213
214fn docs_url(code: &str) -> Option<String> {
218 let _ = code;
219 None
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225
226 #[test]
227 fn docs_url_returns_none_until_docs_site_publishes_errors() {
228 assert!(docs_url("AUTHS-E3001").is_none());
231 assert!(docs_url("UNKNOWN").is_none());
232 }
233
234 #[test]
235 fn lookup_points_at_offline_command_for_auths_codes() {
236 assert_eq!(
237 lookup("AUTHS-E5909"),
238 Some("auths error show AUTHS-E5909".to_string())
239 );
240 assert!(lookup("UNKNOWN").is_none());
241 }
242
243 #[test]
244 fn json_error_carries_offline_lookup_not_docs_url() {
245 let json = build_json(
248 Some("AUTHS-E5909"),
249 "keychain unavailable",
250 Some("Run `auths doctor`"),
251 docs_url("AUTHS-E5909"),
252 lookup("AUTHS-E5909"),
253 );
254 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
255 assert_eq!(parsed["error"]["lookup"], "auths error show AUTHS-E5909");
256 assert!(parsed["error"].get("docs").is_none());
257 assert!(!json.contains("docs.auths.dev/errors"));
258 }
259
260 #[test]
261 fn build_json_without_optional_fields() {
262 let json = build_json(None, "Something went wrong", None, None, None);
263 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
264 assert_eq!(parsed["error"]["message"], "Something went wrong");
265 assert!(parsed["error"].get("code").is_none());
266 assert!(parsed["error"].get("suggestion").is_none());
267 assert!(parsed["error"].get("docs").is_none());
268 assert!(parsed["error"].get("lookup").is_none());
269 }
270
271 #[test]
272 fn render_error_agent_error_text() {
273 let err = Error::new(AgentError::KeyNotFound);
274 render_error(&err, false);
275 }
276
277 #[test]
278 fn render_error_agent_error_json() {
279 let err = Error::new(AgentError::KeyNotFound);
280 render_error(&err, true);
281 }
282
283 #[test]
284 fn render_error_attestation_error_text() {
285 let err = Error::new(AttestationError::IssuerSignatureFailed("bad sig".into()));
286 render_error(&err, false);
287 }
288
289 #[test]
290 fn render_error_unknown_error_text() {
291 let err = anyhow::anyhow!("something unexpected");
292 render_error(&err, false);
293 }
294
295 #[test]
296 fn render_error_unknown_error_json() {
297 let err = anyhow::anyhow!("something unexpected");
298 render_error(&err, true);
299 }
300
301 #[test]
302 fn extract_error_info_returns_code_for_agent_error() {
303 let err = Error::new(AgentError::KeyNotFound);
304 let (code, _, suggestion) = extract_error_info(&err).unwrap();
305 assert_eq!(code, "AUTHS-E3001");
306 assert!(suggestion.is_some());
307 }
308
309 #[test]
310 fn extract_error_info_walks_chain_through_context() {
311 let err: Error = Error::new(AgentError::KeyNotFound).context("operation failed");
312 let (code, _, _) = extract_error_info(&err).unwrap();
313 assert_eq!(code, "AUTHS-E3001");
314 }
315
316 #[test]
317 fn setup_error_storage_delegates_to_inner_code() {
318 let inner = IdStorageError::NotFound("test".into());
319 let sdk_err = auths_sdk::error::SdkStorageError::Identity(inner);
320 let setup_err = SetupError::StorageError(sdk_err);
321 let err = Error::new(setup_err);
322 let (code, _, suggestion) = extract_error_info(&err).unwrap();
323 assert_eq!(code, "AUTHS-E4104");
324 assert!(suggestion.is_some());
325 }
326
327 #[test]
328 fn setup_error_registration_delegates_to_inner_code() {
329 let reg_err = RegistrationError::AlreadyRegistered;
330 let setup_err = SetupError::RegistrationFailed(reg_err);
331 let err = Error::new(setup_err);
332 let (code, _, suggestion) = extract_error_info(&err).unwrap();
333 assert_eq!(code, "AUTHS-E5401");
334 assert!(suggestion.is_some());
335 }
336}