Skip to main content

logbrew_cli/
error.rs

1//! CLI error types and output rendering.
2
3use crate::ISSUE_STATUS_VALUES_NEXT_STEP;
4use std::borrow::Cow;
5
6/// CLI parsing error.
7#[derive(Debug, thiserror::Error, PartialEq, Eq)]
8pub enum CliError {
9    /// Command is missing or unsupported.
10    #[error("unknown or missing command")]
11    UnknownCommand,
12    /// Command name is unsupported.
13    #[error("unknown command: {command}")]
14    UnknownCommandName {
15        /// Unsupported command name.
16        command: String,
17        /// Suggested next step.
18        next: &'static str,
19    },
20    /// Required command argument is missing.
21    #[error("missing argument: {argument}")]
22    MissingArgument {
23        /// Missing argument name.
24        argument: &'static str,
25        /// Argument-specific next step.
26        next: &'static str,
27    },
28    /// Required flag value is missing.
29    #[error("missing value for {flag}")]
30    MissingFlagValue {
31        /// Flag missing a value.
32        flag: &'static str,
33        /// Flag-specific next step.
34        next: &'static str,
35    },
36    /// Flag is present more than once.
37    #[error("duplicate flag: {flag}")]
38    DuplicateFlag {
39        /// Duplicate flag value.
40        flag: &'static str,
41        /// Flag-specific next step.
42        next: &'static str,
43    },
44    /// Positional argument is unsupported for the selected command.
45    #[error("unexpected argument for {command}: {argument}")]
46    UnexpectedArgument {
47        /// Unexpected argument value.
48        argument: String,
49        /// Command name.
50        command: &'static str,
51        /// Command-specific next step.
52        next: &'static str,
53    },
54    /// Flag is unknown for the selected command.
55    #[error("unknown flag: {flag}")]
56    UnknownFlag {
57        /// Unknown flag value.
58        flag: String,
59        /// Command-specific next step.
60        next: &'static str,
61    },
62    /// Flag is known globally but unsupported for the selected command.
63    #[error("unsupported flag for {command}: {flag}")]
64    UnsupportedFlag {
65        /// Unsupported flag value.
66        flag: String,
67        /// Command name.
68        command: &'static str,
69        /// Command-specific next step.
70        next: &'static str,
71    },
72    /// Resource is unsupported for the selected command.
73    #[error("unknown resource: {resource}")]
74    UnknownResource {
75        /// Unsupported resource value.
76        resource: String,
77        /// Command-specific next step.
78        next: &'static str,
79    },
80    /// Issue status is unsupported.
81    #[error("unknown issue status: {0}")]
82    UnknownStatus(String),
83    /// Trace status is unsupported.
84    #[error("unknown trace status: {0}")]
85    UnknownTraceStatus(String),
86    /// Log level is unsupported.
87    #[error("unknown log level: {0}")]
88    UnknownLogLevel(String),
89    /// Row limit is malformed.
90    #[error("invalid limit: {0}")]
91    InvalidLimit(String),
92    /// Minimum trace duration is malformed.
93    #[error("invalid minimum duration: {0}")]
94    InvalidMinDuration(String),
95    /// Pagination mode is unsupported.
96    #[error("unknown pagination mode")]
97    UnknownPagination,
98    /// Action cursor fields are inconsistent.
99    #[error("invalid action cursor: {0}")]
100    InvalidActionCursor(String),
101    /// Log cursor fields are inconsistent.
102    #[error("invalid log cursor: {0}")]
103    InvalidLogCursor(String),
104    /// Issue cursor fields are inconsistent.
105    #[error("invalid issue cursor: {0}")]
106    InvalidIssueCursor(String),
107    /// Support-ticket cursor fields are inconsistent.
108    #[error("invalid support cursor: {0}")]
109    InvalidSupportCursor(String),
110    /// Support-ticket category is unsupported.
111    #[error("unknown support category")]
112    UnknownSupportCategory,
113    /// Support-ticket identifier is not in the public `sup_` form.
114    #[error("invalid support ticket id")]
115    InvalidSupportTicketId,
116    /// Support context retry key cannot be sent as an HTTP header value.
117    #[error("invalid support retry key")]
118    InvalidSupportRetryKey,
119    /// Support context reply syntax is malformed.
120    #[error("invalid support context reply")]
121    InvalidSupportContextReply,
122    /// Support context history syntax is malformed.
123    #[error("invalid support context command")]
124    InvalidSupportContextCommand,
125    /// Support context text is blank or exceeds the public limit.
126    #[error("invalid support context")]
127    InvalidSupportContext,
128    /// Issue investigation syntax is malformed.
129    #[error("invalid issue investigation command")]
130    InvalidInvestigationCommand,
131    /// Project-scoped doctor syntax is malformed.
132    #[error("invalid project doctor command")]
133    InvalidDoctorCommand,
134    /// Secure project creation syntax is malformed.
135    #[error("invalid project create command")]
136    InvalidProjectCreateCommand,
137    /// Account usage read syntax is malformed.
138    #[error("invalid usage command")]
139    InvalidUsageCommand,
140    /// Native debug-artifact command syntax is malformed.
141    #[error("invalid native debug-artifact command")]
142    InvalidNativeDebugCommand,
143    /// Native debug-artifact identity is not canonical.
144    #[error("invalid native debug-artifact identity")]
145    InvalidNativeDebugIdentity,
146    /// Project setup source is malformed.
147    #[error("invalid setup source: {0}")]
148    InvalidSetupSource(String),
149}
150
151/// Runtime error for command execution.
152#[derive(Debug, thiserror::Error)]
153pub enum RuntimeError {
154    /// Command-line parsing failed.
155    #[error(transparent)]
156    Cli(#[from] CliError),
157    /// Filesystem or process I/O failed.
158    #[error(transparent)]
159    Io(#[from] std::io::Error),
160    /// HTTP request failed.
161    #[error(transparent)]
162    Http(#[from] reqwest::Error),
163    /// Auth token was missing for an authenticated API call.
164    #[error("not logged in: run logbrew login")]
165    MissingToken,
166    /// API returned a non-success status.
167    #[error("api returned status {status}: {body}")]
168    Api {
169        /// HTTP status code.
170        status: u16,
171        /// Response body.
172        body: String,
173        /// Stable machine-readable auth source key.
174        auth_source: &'static str,
175        /// Concise human auth label.
176        auth_label: &'static str,
177    },
178    /// Status check could not prove API reachability.
179    #[error("LogBrew API unreachable: {message}")]
180    StatusUnavailable {
181        /// Base API URL checked by the CLI.
182        api_url: String,
183        /// Optional HTTP status code returned by `/health`.
184        status_code: Option<u16>,
185        /// Optional response body returned by `/health`.
186        body: Option<String>,
187        /// Whether a local credential is configured.
188        authenticated: bool,
189        /// Stable machine-readable auth source key.
190        auth_source: &'static str,
191        /// Concise human auth label.
192        auth_label: &'static str,
193        /// Human-readable reachability reason.
194        message: String,
195    },
196    /// Command is recognized but not available yet.
197    #[error("{message}")]
198    Unavailable {
199        /// Human-readable unavailable reason.
200        message: &'static str,
201        /// Suggested fallback action.
202        next: &'static str,
203    },
204    /// A successful investigation response violated the public contract.
205    #[error("issue investigation returned an invalid response")]
206    InvestigationResponseInvalid,
207    /// A local Apple native debug artifact failed validation.
208    #[error("native debug artifact is invalid")]
209    NativeDebugArtifactInvalid,
210    /// A native debug-artifact response violated the public contract.
211    #[error("native debug-artifact response is invalid")]
212    NativeDebugResponseInvalid,
213    /// Exact post-upload lookup verification did not match.
214    #[error("native debug-artifact verification failed")]
215    NativeDebugVerificationFailed,
216}
217
218/// Writes a command-line parsing error for humans or agents.
219///
220/// # Errors
221///
222/// Returns an I/O error if writing to the output stream fails.
223pub fn write_cli_error<W: std::io::Write>(
224    error: &CliError,
225    json: bool,
226    output: &mut W,
227) -> Result<(), std::io::Error> {
228    if json {
229        let body = serde_json::json!({
230            "ok": false,
231            "error": cli_error_code(error),
232            "message": error.to_string(),
233            "next": cli_error_next_step(error),
234        });
235        writeln!(output, "{body}")
236    } else {
237        writeln!(output, "{error}")?;
238        writeln!(output, "Next: {}", cli_error_next_step(error))
239    }
240}
241
242/// Writes a runtime error for humans or agents.
243///
244/// # Errors
245///
246/// Returns an I/O error if writing to the output stream fails.
247pub fn write_runtime_error<W: std::io::Write>(
248    error: &RuntimeError,
249    json: bool,
250    output: &mut W,
251) -> Result<(), std::io::Error> {
252    if json {
253        let body = runtime_error_json(error);
254        writeln!(output, "{body}")
255    } else {
256        write_human_runtime_error(error, output)
257    }
258}
259
260/// Writes a runtime error for human readers.
261fn write_human_runtime_error<W: std::io::Write>(
262    error: &RuntimeError,
263    output: &mut W,
264) -> Result<(), std::io::Error> {
265    match error {
266        RuntimeError::StatusUnavailable {
267            api_url,
268            status_code,
269            body,
270            auth_label,
271            message,
272            ..
273        } => {
274            writeln!(output, "LogBrew API unreachable.")?;
275            writeln!(output, "API: {api_url}")?;
276            writeln!(output, "Auth: {auth_label}")?;
277            if let Some(status_code) = status_code {
278                writeln!(output, "Status: {status_code}")?;
279            }
280            if let Some(body) = body.as_ref().filter(|body| !body.is_empty()) {
281                writeln!(output, "Body: {body}")?;
282            } else {
283                writeln!(output, "Reason: {message}")?;
284            }
285            writeln!(output, "Next: {STATUS_UNAVAILABLE_NEXT_STEP}")?;
286            Ok(())
287        }
288        RuntimeError::Api {
289            status,
290            body,
291            auth_label,
292            ..
293        } => {
294            let api_details = ApiErrorDetails::parse(body);
295            writeln!(output, "{error}")?;
296            if let Some(code) = api_details.code.as_deref() {
297                writeln!(output, "Code: {code}")?;
298            }
299            writeln!(output, "Auth: {auth_label}")?;
300            writeln!(output, "Next: {}", api_next_step(*status, &api_details))
301        }
302        RuntimeError::Cli(_)
303        | RuntimeError::Io(_)
304        | RuntimeError::Http(_)
305        | RuntimeError::MissingToken
306        | RuntimeError::InvestigationResponseInvalid
307        | RuntimeError::NativeDebugArtifactInvalid
308        | RuntimeError::NativeDebugResponseInvalid
309        | RuntimeError::NativeDebugVerificationFailed
310        | RuntimeError::Unavailable { .. } => {
311            writeln!(output, "{error}")?;
312            writeln!(output, "Next: {}", runtime_error_next_step(error))?;
313            Ok(())
314        }
315    }
316}
317
318/// Builds a JSON runtime error body for agents.
319fn runtime_error_json(error: &RuntimeError) -> serde_json::Value {
320    match error {
321        RuntimeError::MissingToken
322        | RuntimeError::Unavailable { .. }
323        | RuntimeError::InvestigationResponseInvalid
324        | RuntimeError::NativeDebugArtifactInvalid
325        | RuntimeError::NativeDebugResponseInvalid
326        | RuntimeError::NativeDebugVerificationFailed
327        | RuntimeError::Io(_)
328        | RuntimeError::Http(_) => serde_json::json!({
329            "ok": false,
330            "error": runtime_error_code(error),
331            "message": error.to_string(),
332            "next": runtime_error_next_step(error),
333        }),
334        RuntimeError::Api {
335            status,
336            body,
337            auth_source,
338            ..
339        } => {
340            let api_details = ApiErrorDetails::parse(body);
341            let next = api_next_step(*status, &api_details);
342            serde_json::json!({
343                "ok": false,
344                "error": runtime_error_code(error),
345                "message": error.to_string(),
346                "status": status,
347                "body": body,
348                "api_error": api_details.error.as_deref(),
349                "api_code": api_details.code.as_deref(),
350                "api_next": api_details.next.as_deref(),
351                "auth_source": auth_source,
352                "next": next,
353            })
354        }
355        RuntimeError::StatusUnavailable {
356            api_url,
357            status_code,
358            body,
359            authenticated,
360            auth_source,
361            message,
362            ..
363        } => serde_json::json!({
364            "ok": false,
365            "error": runtime_error_code(error),
366            "status": "unreachable",
367            "status_code": status_code,
368            "body": body,
369            "api_url": api_url,
370            "authenticated": authenticated,
371            "auth_source": auth_source,
372            "message": message,
373            "next": runtime_error_next_step(error),
374        }),
375        RuntimeError::Cli(error) => serde_json::json!({
376            "ok": false,
377            "error": cli_error_code(error),
378            "message": error.to_string(),
379            "next": cli_error_next_step(error),
380        }),
381    }
382}
383
384/// Returns a stable machine-readable parse error code.
385const fn cli_error_code(error: &CliError) -> &'static str {
386    match error {
387        CliError::UnknownCommand | CliError::UnknownCommandName { .. } => "unknown_command",
388        CliError::MissingArgument { .. } => "missing_argument",
389        CliError::MissingFlagValue { .. } => "missing_flag_value",
390        CliError::DuplicateFlag { .. } => "duplicate_flag",
391        CliError::UnexpectedArgument { .. } => "unexpected_argument",
392        CliError::UnknownFlag { .. } => "unknown_flag",
393        CliError::UnsupportedFlag { .. } => "unsupported_flag",
394        CliError::UnknownResource { .. } => "unknown_resource",
395        CliError::UnknownStatus(_) => "unknown_status",
396        CliError::UnknownTraceStatus(_) => "unknown_trace_status",
397        CliError::UnknownLogLevel(_) => "unknown_log_level",
398        CliError::InvalidLimit(_) => "invalid_limit",
399        CliError::InvalidMinDuration(_) => "invalid_min_duration",
400        CliError::UnknownPagination => "unknown_pagination",
401        CliError::InvalidActionCursor(_) => "invalid_action_cursor",
402        CliError::InvalidLogCursor(_) => "invalid_log_cursor",
403        CliError::InvalidIssueCursor(_) => "invalid_issue_cursor",
404        CliError::InvalidSupportCursor(_) => "invalid_support_cursor",
405        CliError::UnknownSupportCategory => "unknown_support_category",
406        CliError::InvalidSupportTicketId => "invalid_support_ticket_id",
407        CliError::InvalidSupportRetryKey => "invalid_support_retry_key",
408        CliError::InvalidSupportContextReply => "invalid_support_context_reply",
409        CliError::InvalidSupportContextCommand => "invalid_support_context_command",
410        CliError::InvalidSupportContext => "invalid_support_context",
411        CliError::InvalidInvestigationCommand => "invalid_investigation_command",
412        CliError::InvalidDoctorCommand => "invalid_doctor_command",
413        CliError::InvalidProjectCreateCommand => "invalid_project_create_command",
414        CliError::InvalidUsageCommand => "invalid_usage_command",
415        CliError::InvalidNativeDebugCommand | CliError::InvalidNativeDebugIdentity => {
416            "invalid_native_debug_command"
417        }
418        CliError::InvalidSetupSource(_) => "invalid_setup_source",
419    }
420}
421
422/// Returns the next step for a parse error.
423const fn cli_error_next_step(error: &CliError) -> &'static str {
424    match error {
425        CliError::InvalidLimit(_) => "use --limit with a positive whole number",
426        CliError::InvalidMinDuration(_) => "use --min-duration-ms with a non-negative whole number",
427        CliError::UnknownPagination
428        | CliError::InvalidActionCursor(_)
429        | CliError::InvalidLogCursor(_)
430        | CliError::InvalidIssueCursor(_)
431        | CliError::InvalidSupportCursor(_) => {
432            "use --pagination cursor alone for the first page, then use --cursor-time and --cursor-id together from next_cursor"
433        }
434        CliError::UnknownSupportCategory => {
435            "use sdk_install_failure, ingest_failure, auth_failure, project_setup, dashboard_issue, docs_confusion, cli_issue, mobile_issue, billing_question, or other"
436        }
437        CliError::InvalidSupportTicketId => {
438            "use the ticket_id returned by logbrew support create or list"
439        }
440        CliError::InvalidSupportRetryKey => {
441            "use --retry-key with 1 to 128 visible ASCII characters and reuse it only for an exact retry"
442        }
443        CliError::InvalidSupportContextReply => {
444            "use support reply <ticket_id> --context <text> --retry-key <key>"
445        }
446        CliError::InvalidSupportContextCommand => {
447            "use support context <ticket_id> with optional --json"
448        }
449        CliError::InvalidSupportContext => {
450            "use --context with 1 to 4000 characters after trimming whitespace"
451        }
452        CliError::InvalidInvestigationCommand => {
453            "use logbrew investigate issue <issue_id> with optional --json"
454        }
455        CliError::InvalidDoctorCommand => {
456            "use logbrew doctor --project <project_id> with optional --json"
457        }
458        CliError::InvalidProjectCreateCommand => {
459            "use logbrew projects create <name> --ingest-key-file <path> with optional --runtime, --environment, --abandon-retry, and --json"
460        }
461        CliError::InvalidUsageCommand => "use logbrew usage with optional --json",
462        CliError::InvalidNativeDebugCommand => {
463            "use logbrew debug-artifacts upload <path> --project <project_id> --release <release> --environment <environment> --service <service>"
464        }
465        CliError::InvalidNativeDebugIdentity => {
466            "use a lowercase UUID and architecture arm64, arm64e, or x86_64"
467        }
468        CliError::InvalidSetupSource(_) => "use --source api, cli, or sdk",
469        CliError::MissingArgument { next, .. }
470        | CliError::MissingFlagValue { next, .. }
471        | CliError::DuplicateFlag { next, .. }
472        | CliError::UnexpectedArgument { next, .. }
473        | CliError::UnsupportedFlag { next, .. }
474        | CliError::UnknownFlag { next, .. }
475        | CliError::UnknownResource { next, .. }
476        | CliError::UnknownCommandName { next, .. } => next,
477        CliError::UnknownCommand => "run logbrew --help",
478        CliError::UnknownStatus(_) => ISSUE_STATUS_VALUES_NEXT_STEP,
479        CliError::UnknownTraceStatus(_) => "use --status error or --status ok",
480        CliError::UnknownLogLevel(_) => "use one of info, warning, error, critical",
481    }
482}
483
484/// Returns a stable machine-readable runtime error code.
485const fn runtime_error_code(error: &RuntimeError) -> &'static str {
486    match error {
487        RuntimeError::Cli(error) => cli_error_code(error),
488        RuntimeError::Io(_) => "io_error",
489        RuntimeError::Http(_) => "http_error",
490        RuntimeError::MissingToken => "not_logged_in",
491        RuntimeError::Api { .. } => "api_error",
492        RuntimeError::StatusUnavailable { .. } => "status_unreachable",
493        RuntimeError::Unavailable { .. } => "unavailable",
494        RuntimeError::InvestigationResponseInvalid => "investigation_response_invalid",
495        RuntimeError::NativeDebugArtifactInvalid => "native_debug_artifact_invalid",
496        RuntimeError::NativeDebugResponseInvalid => "native_debug_response_invalid",
497        RuntimeError::NativeDebugVerificationFailed => "native_debug_verification_failed",
498    }
499}
500
501/// Next step for failed `status` reachability checks.
502const STATUS_UNAVAILABLE_NEXT_STEP: &str = "check LOGBREW_API_URL or network";
503
504/// Parsed public API error details.
505#[derive(Debug, Clone, Default, PartialEq, Eq)]
506struct ApiErrorDetails {
507    /// Human-readable backend error message.
508    error: Option<String>,
509    /// Stable backend error code.
510    code: Option<String>,
511    /// Backend-provided recovery step.
512    next: Option<String>,
513}
514
515impl ApiErrorDetails {
516    /// Parses additive backend error fields from an API response body.
517    fn parse(body: &str) -> Self {
518        let Ok(value) = serde_json::from_str::<serde_json::Value>(body) else {
519            return Self::default();
520        };
521
522        Self {
523            error: json_string_field(&value, "error"),
524            code: json_string_field(&value, "code"),
525            next: json_string_field(&value, "next"),
526        }
527    }
528}
529
530/// Extracts a non-empty string field from a JSON object.
531fn json_string_field(value: &serde_json::Value, key: &str) -> Option<String> {
532    value
533        .get(key)
534        .and_then(serde_json::Value::as_str)
535        .map(str::trim)
536        .filter(|value| !value.is_empty())
537        .map(ToOwned::to_owned)
538}
539
540/// Returns a useful next step for runtime errors when one is known.
541fn runtime_error_next_step(error: &RuntimeError) -> Cow<'static, str> {
542    match error {
543        RuntimeError::Api { status, body, .. } => {
544            let api_details = ApiErrorDetails::parse(body);
545            api_next_step(*status, &api_details)
546        }
547        RuntimeError::Cli(_)
548        | RuntimeError::Io(_)
549        | RuntimeError::Http(_)
550        | RuntimeError::MissingToken
551        | RuntimeError::InvestigationResponseInvalid
552        | RuntimeError::NativeDebugArtifactInvalid
553        | RuntimeError::NativeDebugResponseInvalid
554        | RuntimeError::NativeDebugVerificationFailed
555        | RuntimeError::StatusUnavailable { .. }
556        | RuntimeError::Unavailable { .. } => {
557            Cow::Borrowed(fallback_runtime_error_next_step(error))
558        }
559    }
560}
561
562/// Returns the API next step, preferring backend guidance when available.
563fn api_next_step(status: u16, api_details: &ApiErrorDetails) -> Cow<'static, str> {
564    api_details.next.as_ref().map_or_else(
565        || Cow::Borrowed(fallback_api_next_step(status)),
566        |next| Cow::Owned(next.clone()),
567    )
568}
569
570/// Returns the CLI fallback next step for an API status.
571const fn fallback_api_next_step(status: u16) -> &'static str {
572    match status {
573        401 | 403 => "run logbrew login",
574        400 | 422 => "check command arguments or filters",
575        404 => "check the resource id or filters",
576        429 => "retry later",
577        500..=599 => "check LOGBREW_API_URL or retry later",
578        _ => "check command arguments or retry later",
579    }
580}
581
582/// Returns a useful fallback next step for runtime errors when one is known.
583const fn fallback_runtime_error_next_step(error: &RuntimeError) -> &'static str {
584    match error {
585        RuntimeError::Cli(error) => cli_error_next_step(error),
586        RuntimeError::MissingToken => "run logbrew login",
587        RuntimeError::Api { status, .. } => fallback_api_next_step(*status),
588        RuntimeError::StatusUnavailable { .. } | RuntimeError::Http(_) => {
589            STATUS_UNAVAILABLE_NEXT_STEP
590        }
591        RuntimeError::Unavailable { next, .. } => next,
592        RuntimeError::InvestigationResponseInvalid => {
593            "retry the issue investigation; if it repeats, report the public response contract"
594        }
595        RuntimeError::NativeDebugArtifactInvalid => {
596            "provide one validated Apple dSYM bundle or Mach-O debug object"
597        }
598        RuntimeError::NativeDebugResponseInvalid => {
599            "retry the native debug-artifact request; if it repeats, report the public response contract"
600        }
601        RuntimeError::NativeDebugVerificationFailed => {
602            "retry exact native debug-artifact lookup before using native symbolication"
603        }
604        RuntimeError::Io(_) => "check local files and permissions",
605    }
606}