Skip to main content

auths_cli/errors/
renderer.rs

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
22/// Render an error to stderr in either text or JSON format.
23///
24/// Args:
25/// * `err`: The error to render.
26/// * `json_mode`: If `true`, output structured JSON; otherwise styled text.
27pub 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
35/// Try to extract `AuthsErrorInfo` from an `anyhow::Error` by downcasting
36/// through all known error types. Walks the full error chain so that
37/// `.with_context()` wrapping doesn't hide typed errors.
38fn 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                    // SAFETY: we leak the String to get a &'static str because
46                    // the caller consumes it immediately in the same scope.
47                    // This is bounded to a single error render per invocation.
48                    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        // The offline lookup is the real, working next step; the docs site's
107        // /errors/ path 404s, so it is not advertised until it is published.
108        if code.starts_with("AUTHS-E") {
109            eprintln!("  look up: auths error show {code}");
110        }
111        return;
112    }
113
114    // Fallback for generic anyhow::Error
115    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        // Emit the offline lookup, not the 404 docs URL, as the machine-readable
163        // next step (mirrors the text renderer's `look up:` line).
164        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
205/// The offline lookup command for an error code — the working next step.
206fn 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
214/// Deep docs link for an error code. Returns `None` until the docs site publishes
215/// `docs/errors/` — its `/errors/` path 404s today, so `auths error show` (the
216/// renderer's look-up line) is the actionable path instead.
217fn 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        // The docs `/errors/` path 404s, so no docs URL is emitted for any code —
229        // `auths error show` is the working next step instead.
230        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        // The machine-readable body advertises `auths error show <CODE>` and drops
246        // the dead docs URL — the same repointing the text renderer does.
247        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}