Skip to main content

auths_cli/errors/
renderer.rs

1use anyhow::Error;
2use auths_core::error::{AgentError, AuthsErrorInfo as CoreErrorInfo};
3use auths_sdk::error::{DeviceError, SetupError};
4use auths_sdk::signing::SigningError;
5use auths_verifier::{AttestationError, AuthsErrorInfo as VerifierErrorInfo};
6use colored::Colorize;
7
8use crate::errors::cli_error::CliError;
9use crate::ux::format::Output;
10
11const DOCS_BASE_URL: &str = "https://docs.auths.dev";
12
13/// Render an error to stderr in either text or JSON format.
14///
15/// Attempts to downcast the anyhow error to known Auths error types
16/// (`AgentError`, `AttestationError`) to extract structured metadata
17/// (error code, suggestion, docs URL). Falls back to a plain message
18/// for unknown error types.
19pub fn render_error(err: &Error, json_mode: bool) {
20    if json_mode {
21        render_json(err);
22    } else {
23        render_text(err);
24    }
25}
26
27/// Render a styled error message to stderr.
28fn render_text(err: &Error) {
29    let out = Output::new();
30
31    // Try CliError first (most specific)
32    if let Some(cli_err) = err.downcast_ref::<CliError>() {
33        eprintln!("\n{} {}", "Error:".red().bold(), cli_err);
34        eprintln!("\n{}", cli_err.suggestion());
35        if let Some(url) = cli_err.docs_url() {
36            eprintln!("Docs: {}", url);
37        }
38        eprintln!();
39        return;
40    }
41
42    if let Some(signing_err) = err.downcast_ref::<SigningError>() {
43        let message = format!("{signing_err}");
44        out.print_error(&out.bold(&message));
45        eprintln!();
46        let suggestion = match signing_err {
47            SigningError::PassphraseExhausted { attempts } => Some(format!(
48                "All {attempts} passphrase attempt(s) failed.\n     Forgot your passphrase? Run: auths key reset <alias>"
49            )),
50            SigningError::IdentityFrozen(_) => {
51                Some("To unfreeze: auths emergency unfreeze".to_string())
52            }
53            SigningError::KeychainUnavailable(_) => Some(format!(
54                "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/"
55            )),
56            _ => None,
57        };
58        if let Some(suggestion) = suggestion {
59            eprintln!("  fix:  {suggestion}");
60        }
61        return;
62    }
63
64    if let Some(agent_err) = err.downcast_ref::<AgentError>() {
65        let code = CoreErrorInfo::error_code(agent_err);
66        let message = format!("{agent_err}");
67        out.print_error(&out.bold(&message));
68        eprintln!();
69        if let Some(suggestion) = CoreErrorInfo::suggestion(agent_err) {
70            eprintln!("  fix:  {suggestion}");
71        }
72        if let Some(url) = docs_url(code) {
73            eprintln!("  docs: {url}");
74        }
75    } else if let Some(att_err) = err.downcast_ref::<AttestationError>() {
76        let code = VerifierErrorInfo::error_code(att_err);
77        let message = format!("{att_err}");
78        out.print_error(&out.bold(&message));
79        eprintln!();
80        if let Some(suggestion) = VerifierErrorInfo::suggestion(att_err) {
81            eprintln!("  fix:  {suggestion}");
82        }
83        if let Some(url) = docs_url(code) {
84            eprintln!("  docs: {url}");
85        }
86    } else if let Some(setup_err) = err.downcast_ref::<SetupError>() {
87        let code = CoreErrorInfo::error_code(setup_err);
88        let message = format!("{setup_err}");
89        out.print_error(&out.bold(&message));
90        eprintln!();
91        if let Some(suggestion) = CoreErrorInfo::suggestion(setup_err) {
92            eprintln!("  fix:  {suggestion}");
93        }
94        if let Some(url) = docs_url(code) {
95            eprintln!("  docs: {url}");
96        }
97    } else if let Some(device_err) = err.downcast_ref::<DeviceError>() {
98        let code = CoreErrorInfo::error_code(device_err);
99        let message = format!("{device_err}");
100        out.print_error(&out.bold(&message));
101        eprintln!();
102        if let Some(suggestion) = CoreErrorInfo::suggestion(device_err) {
103            eprintln!("  fix:  {suggestion}");
104        }
105        if let Some(url) = docs_url(code) {
106            eprintln!("  docs: {url}");
107        }
108    } else {
109        // Fallback for generic anyhow::Error
110        let msg = err.to_string();
111        let suggestion = match msg.as_str() {
112            s if s.contains("No identity found") => Some(format!(
113                "Run `auths init` to create one, or `auths key import` to restore from a backup.\n     See: {DOCS_BASE_URL}/getting-started/quickstart/"
114            )),
115            s if s.contains("keychain") || s.contains("Secret Service") => Some(format!(
116                "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/"
117            )),
118            s if s.contains("ssh-keygen") && s.contains("not found") => Some(
119                "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()
120            ),
121            _ => None,
122        };
123
124        if let Some(suggestion) = suggestion {
125            out.print_error(&msg);
126            eprintln!("\n{suggestion}");
127        } else {
128            // Unknown error — print the message and any causal chain
129            out.print_error(&format!("{err}"));
130            for cause in err.chain().skip(1) {
131                eprintln!("  caused by: {cause}");
132            }
133        }
134    }
135}
136
137/// Render a JSON error object to stderr.
138fn render_json(err: &Error) {
139    let json = if let Some(cli_err) = err.downcast_ref::<CliError>() {
140        build_json(
141            None,
142            &format!("{cli_err}"),
143            Some(cli_err.suggestion()),
144            cli_err.docs_url().map(|s| s.to_string()),
145        )
146    } else if let Some(signing_err) = err.downcast_ref::<SigningError>() {
147        let suggestion = match signing_err {
148            SigningError::PassphraseExhausted { attempts } => Some(format!(
149                "All {} passphrase attempt(s) failed. Run: auths key reset <alias>",
150                attempts
151            )),
152            SigningError::IdentityFrozen(_) => {
153                Some("To unfreeze: auths emergency unfreeze".to_string())
154            }
155            _ => None,
156        };
157        build_json(None, &format!("{signing_err}"), suggestion.as_deref(), None)
158    } else if let Some(agent_err) = err.downcast_ref::<AgentError>() {
159        let code = CoreErrorInfo::error_code(agent_err);
160        build_json(
161            Some(code),
162            &format!("{agent_err}"),
163            CoreErrorInfo::suggestion(agent_err),
164            docs_url(code),
165        )
166    } else if let Some(att_err) = err.downcast_ref::<AttestationError>() {
167        let code = VerifierErrorInfo::error_code(att_err);
168        build_json(
169            Some(code),
170            &format!("{att_err}"),
171            VerifierErrorInfo::suggestion(att_err),
172            docs_url(code),
173        )
174    } else if let Some(setup_err) = err.downcast_ref::<SetupError>() {
175        let code = CoreErrorInfo::error_code(setup_err);
176        build_json(
177            Some(code),
178            &format!("{setup_err}"),
179            CoreErrorInfo::suggestion(setup_err),
180            docs_url(code),
181        )
182    } else if let Some(device_err) = err.downcast_ref::<DeviceError>() {
183        let code = CoreErrorInfo::error_code(device_err);
184        build_json(
185            Some(code),
186            &format!("{device_err}"),
187            CoreErrorInfo::suggestion(device_err),
188            docs_url(code),
189        )
190    } else {
191        build_json(None, &format!("{err}"), None, None)
192    };
193
194    eprintln!("{json}");
195}
196
197/// Build a JSON error string from optional fields.
198fn build_json(
199    code: Option<&str>,
200    message: &str,
201    suggestion: Option<&str>,
202    docs: Option<String>,
203) -> String {
204    let mut map = serde_json::Map::new();
205    if let Some(c) = code {
206        map.insert("code".into(), serde_json::Value::String(c.into()));
207    }
208    map.insert("message".into(), serde_json::Value::String(message.into()));
209    if let Some(s) = suggestion {
210        map.insert("suggestion".into(), serde_json::Value::String(s.into()));
211    }
212    if let Some(d) = docs {
213        map.insert("docs".into(), serde_json::Value::String(d));
214    }
215
216    let wrapper = serde_json::json!({ "error": map });
217    serde_json::to_string_pretty(&wrapper)
218        .unwrap_or_else(|_| format!("{{\"error\":{{\"message\":\"{message}\"}}}}"))
219}
220
221/// Map an error code to a docs URL. Returns `None` for codes that don't
222/// have actionable documentation.
223fn docs_url(code: &str) -> Option<String> {
224    match code {
225        "AUTHS_KEY_NOT_FOUND"
226        | "AUTHS_INCORRECT_PASSPHRASE"
227        | "AUTHS_MISSING_PASSPHRASE"
228        | "AUTHS_BACKEND_UNAVAILABLE"
229        | "AUTHS_STORAGE_LOCKED"
230        | "AUTHS_BACKEND_INIT_FAILED"
231        | "AUTHS_AGENT_LOCKED"
232        | "AUTHS_ISSUER_SIG_FAILED"
233        | "AUTHS_DEVICE_SIG_FAILED"
234        | "AUTHS_ATTESTATION_EXPIRED"
235        | "AUTHS_ATTESTATION_REVOKED"
236        | "AUTHS_TIMESTAMP_IN_FUTURE"
237        | "AUTHS_MISSING_CAPABILITY"
238        | "AUTHS_DID_RESOLUTION_ERROR"
239        | "AUTHS_ORG_VERIFICATION_FAILED"
240        | "AUTHS_ORG_ATTESTATION_EXPIRED"
241        | "AUTHS_ORG_DID_RESOLUTION_FAILED"
242        | "AUTHS_GIT_ERROR" => Some(format!("{DOCS_BASE_URL}/errors/#{code}")),
243        _ => None,
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn docs_url_returns_some_for_known_codes() {
253        let url = docs_url("AUTHS_KEY_NOT_FOUND");
254        assert_eq!(
255            url,
256            Some(format!("{DOCS_BASE_URL}/errors/#AUTHS_KEY_NOT_FOUND"))
257        );
258    }
259
260    #[test]
261    fn docs_url_returns_none_for_unknown_codes() {
262        assert!(docs_url("AUTHS_IO_ERROR").is_none());
263        assert!(docs_url("UNKNOWN").is_none());
264    }
265
266    #[test]
267    fn build_json_with_all_fields() {
268        let json = build_json(
269            Some("AUTHS_KEY_NOT_FOUND"),
270            "Key not found",
271            Some("Run `auths key list`"),
272            Some(format!("{DOCS_BASE_URL}/errors/#AUTHS_KEY_NOT_FOUND")),
273        );
274        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
275        assert_eq!(parsed["error"]["code"], "AUTHS_KEY_NOT_FOUND");
276        assert_eq!(parsed["error"]["message"], "Key not found");
277        assert_eq!(parsed["error"]["suggestion"], "Run `auths key list`");
278        assert!(
279            parsed["error"]["docs"]
280                .as_str()
281                .unwrap()
282                .contains("AUTHS_KEY_NOT_FOUND")
283        );
284    }
285
286    #[test]
287    fn build_json_without_optional_fields() {
288        let json = build_json(None, "Something went wrong", None, None);
289        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
290        assert_eq!(parsed["error"]["message"], "Something went wrong");
291        assert!(parsed["error"].get("code").is_none());
292        assert!(parsed["error"].get("suggestion").is_none());
293        assert!(parsed["error"].get("docs").is_none());
294    }
295
296    #[test]
297    fn render_error_agent_error_text() {
298        let err = Error::new(AgentError::KeyNotFound);
299        // Should not panic — just writes to stderr
300        render_error(&err, false);
301    }
302
303    #[test]
304    fn render_error_agent_error_json() {
305        let err = Error::new(AgentError::KeyNotFound);
306        render_error(&err, true);
307    }
308
309    #[test]
310    fn render_error_attestation_error_text() {
311        let err = Error::new(AttestationError::IssuerSignatureFailed("bad sig".into()));
312        render_error(&err, false);
313    }
314
315    #[test]
316    fn render_error_unknown_error_text() {
317        let err = anyhow::anyhow!("something unexpected");
318        render_error(&err, false);
319    }
320
321    #[test]
322    fn render_error_unknown_error_json() {
323        let err = anyhow::anyhow!("something unexpected");
324        render_error(&err, true);
325    }
326}