Skip to main content

auths_cli/errors/
renderer.rs

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