Skip to main content

auths_cli/errors/
renderer.rs

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