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    /// Authenticated project catalog syntax is malformed.
138    #[error("invalid projects command")]
139    InvalidProjectsCommand,
140    /// Account usage read syntax is malformed.
141    #[error("invalid usage command")]
142    InvalidUsageCommand,
143    /// Native debug-artifact command syntax is malformed.
144    #[error("invalid native debug-artifact command")]
145    InvalidNativeDebugCommand,
146    /// Native debug-artifact identity is not canonical.
147    #[error("invalid native debug-artifact identity")]
148    InvalidNativeDebugIdentity,
149    /// Project setup source is malformed.
150    #[error("invalid setup source: {0}")]
151    InvalidSetupSource(String),
152}
153
154/// Runtime error for command execution.
155#[derive(Debug, thiserror::Error)]
156pub enum RuntimeError {
157    /// Command-line parsing failed.
158    #[error(transparent)]
159    Cli(#[from] CliError),
160    /// Filesystem or process I/O failed.
161    #[error(transparent)]
162    Io(#[from] std::io::Error),
163    /// HTTP request failed.
164    #[error(transparent)]
165    Http(#[from] reqwest::Error),
166    /// Auth token was missing for an authenticated API call.
167    #[error("not logged in: run logbrew login")]
168    MissingToken,
169    /// API returned a non-success status.
170    #[error("api returned status {status}: {body}")]
171    Api {
172        /// HTTP status code.
173        status: u16,
174        /// Response body.
175        body: String,
176        /// Stable machine-readable auth source key.
177        auth_source: &'static str,
178        /// Concise human auth label.
179        auth_label: &'static str,
180    },
181    /// Status check could not prove API reachability.
182    #[error("LogBrew API unreachable: {message}")]
183    StatusUnavailable {
184        /// Base API URL checked by the CLI.
185        api_url: String,
186        /// Optional HTTP status code returned by `/health`.
187        status_code: Option<u16>,
188        /// Optional response body returned by `/health`.
189        body: Option<String>,
190        /// Whether a local credential is configured.
191        authenticated: bool,
192        /// Stable machine-readable auth source key.
193        auth_source: &'static str,
194        /// Concise human auth label.
195        auth_label: &'static str,
196        /// Human-readable reachability reason.
197        message: String,
198    },
199    /// Command is recognized but not available yet.
200    #[error("{message}")]
201    Unavailable {
202        /// Human-readable unavailable reason.
203        message: &'static str,
204        /// Suggested fallback action.
205        next: &'static str,
206    },
207    /// A successful investigation response violated the public contract.
208    #[error("issue investigation returned an invalid response")]
209    InvestigationResponseInvalid,
210    /// A local Apple native debug artifact failed validation.
211    #[error("native debug artifact is invalid")]
212    NativeDebugArtifactInvalid,
213    /// A native debug-artifact response violated the public contract.
214    #[error("native debug-artifact response is invalid")]
215    NativeDebugResponseInvalid,
216    /// Exact post-upload lookup verification did not match.
217    #[error("native debug-artifact verification failed")]
218    NativeDebugVerificationFailed,
219}
220
221/// Writes a command-line parsing error for humans or agents.
222///
223/// # Errors
224///
225/// Returns an I/O error if writing to the output stream fails.
226pub fn write_cli_error<W: std::io::Write>(
227    error: &CliError,
228    json: bool,
229    output: &mut W,
230) -> Result<(), std::io::Error> {
231    if json {
232        let body = serde_json::json!({
233            "ok": false,
234            "error": cli_error_code(error),
235            "message": error.to_string(),
236            "next": cli_error_next_step(error),
237        });
238        writeln!(output, "{body}")
239    } else {
240        writeln!(output, "{error}")?;
241        writeln!(output, "Next: {}", cli_error_next_step(error))
242    }
243}
244
245/// Writes a runtime error for humans or agents.
246///
247/// # Errors
248///
249/// Returns an I/O error if writing to the output stream fails.
250pub fn write_runtime_error<W: std::io::Write>(
251    error: &RuntimeError,
252    json: bool,
253    output: &mut W,
254) -> Result<(), std::io::Error> {
255    if json {
256        let body = runtime_error_json(error);
257        writeln!(output, "{body}")
258    } else {
259        write_human_runtime_error(error, output)
260    }
261}
262
263/// Writes one body-free native debug-artifact JSON error for agents.
264///
265/// # Errors
266///
267/// Returns an I/O error if writing to the output stream fails.
268pub fn write_native_debug_runtime_error<W: std::io::Write>(
269    error: &RuntimeError,
270    output: &mut W,
271) -> Result<(), std::io::Error> {
272    let body = match error {
273        RuntimeError::Api { status, .. } => {
274            let (code, next) = native_debug_api_recovery(*status);
275            serde_json::json!({
276                "ok": false,
277                "error": code,
278                "status": status,
279                "next": next,
280            })
281        }
282        RuntimeError::Cli(_)
283        | RuntimeError::Io(_)
284        | RuntimeError::Http(_)
285        | RuntimeError::MissingToken
286        | RuntimeError::StatusUnavailable { .. }
287        | RuntimeError::Unavailable { .. }
288        | RuntimeError::InvestigationResponseInvalid
289        | RuntimeError::NativeDebugArtifactInvalid
290        | RuntimeError::NativeDebugResponseInvalid
291        | RuntimeError::NativeDebugVerificationFailed => serde_json::json!({
292            "ok": false,
293            "error": runtime_error_code(error),
294            "next": fallback_runtime_error_next_step(error),
295        }),
296    };
297    writeln!(output, "{body}")
298}
299
300/// Returns fixed native debug-artifact recovery derived only from HTTP status.
301const fn native_debug_api_recovery(status: u16) -> (&'static str, &'static str) {
302    match status {
303        400 => (
304            "validation_failed",
305            "check the artifact identity and request scope, then retry",
306        ),
307        401 | 403 => (
308            "unauthorized",
309            "sign in and retry the native debug-artifact command",
310        ),
311        404 => (
312            "not_found",
313            "check the exact project, release, environment, service, UUID, and architecture",
314        ),
315        413 => (
316            "payload_too_large",
317            "reduce the native debug-artifact upload below the documented size limits and retry",
318        ),
319        422 => (
320            "validation_failed",
321            "send manifest and debug_file_N multipart parts from LogBrew Apple release tooling",
322        ),
323        429 => (
324            "rate_limited",
325            "retry the same native debug-artifact command later",
326        ),
327        500..=599 => (
328            "server_error",
329            "retry the same native debug-artifact command later",
330        ),
331        _ => (
332            "unexpected_response",
333            "retry the native debug-artifact command",
334        ),
335    }
336}
337
338/// Writes a runtime error for human readers.
339fn write_human_runtime_error<W: std::io::Write>(
340    error: &RuntimeError,
341    output: &mut W,
342) -> Result<(), std::io::Error> {
343    match error {
344        RuntimeError::StatusUnavailable {
345            api_url,
346            status_code,
347            body,
348            auth_label,
349            message,
350            ..
351        } => {
352            writeln!(output, "LogBrew API unreachable.")?;
353            writeln!(output, "API: {api_url}")?;
354            writeln!(output, "Auth: {auth_label}")?;
355            if let Some(status_code) = status_code {
356                writeln!(output, "Status: {status_code}")?;
357            }
358            if let Some(body) = body.as_ref().filter(|body| !body.is_empty()) {
359                writeln!(output, "Body: {body}")?;
360            } else {
361                writeln!(output, "Reason: {message}")?;
362            }
363            writeln!(output, "Next: {STATUS_UNAVAILABLE_NEXT_STEP}")?;
364            Ok(())
365        }
366        RuntimeError::Api {
367            status,
368            body,
369            auth_label,
370            ..
371        } => {
372            let api_details = ApiErrorDetails::parse(body);
373            writeln!(output, "{error}")?;
374            if let Some(code) = api_details.code.as_deref() {
375                writeln!(output, "Code: {code}")?;
376            }
377            writeln!(output, "Auth: {auth_label}")?;
378            writeln!(output, "Next: {}", api_next_step(*status, &api_details))
379        }
380        RuntimeError::Cli(_)
381        | RuntimeError::Io(_)
382        | RuntimeError::Http(_)
383        | RuntimeError::MissingToken
384        | RuntimeError::InvestigationResponseInvalid
385        | RuntimeError::NativeDebugArtifactInvalid
386        | RuntimeError::NativeDebugResponseInvalid
387        | RuntimeError::NativeDebugVerificationFailed
388        | RuntimeError::Unavailable { .. } => {
389            writeln!(output, "{error}")?;
390            writeln!(output, "Next: {}", runtime_error_next_step(error))?;
391            Ok(())
392        }
393    }
394}
395
396/// Builds a JSON runtime error body for agents.
397fn runtime_error_json(error: &RuntimeError) -> serde_json::Value {
398    match error {
399        RuntimeError::MissingToken
400        | RuntimeError::Unavailable { .. }
401        | RuntimeError::InvestigationResponseInvalid
402        | RuntimeError::NativeDebugArtifactInvalid
403        | RuntimeError::NativeDebugResponseInvalid
404        | RuntimeError::NativeDebugVerificationFailed
405        | RuntimeError::Io(_)
406        | RuntimeError::Http(_) => serde_json::json!({
407            "ok": false,
408            "error": runtime_error_code(error),
409            "message": error.to_string(),
410            "next": runtime_error_next_step(error),
411        }),
412        RuntimeError::Api {
413            status,
414            body,
415            auth_source,
416            ..
417        } => {
418            let api_details = ApiErrorDetails::parse(body);
419            let next = api_next_step(*status, &api_details);
420            serde_json::json!({
421                "ok": false,
422                "error": runtime_error_code(error),
423                "message": error.to_string(),
424                "status": status,
425                "body": body,
426                "api_error": api_details.error.as_deref(),
427                "api_code": api_details.code.as_deref(),
428                "api_next": api_details.next.as_deref(),
429                "auth_source": auth_source,
430                "next": next,
431            })
432        }
433        RuntimeError::StatusUnavailable {
434            api_url,
435            status_code,
436            body,
437            authenticated,
438            auth_source,
439            message,
440            ..
441        } => serde_json::json!({
442            "ok": false,
443            "error": runtime_error_code(error),
444            "status": "unreachable",
445            "status_code": status_code,
446            "body": body,
447            "api_url": api_url,
448            "authenticated": authenticated,
449            "auth_source": auth_source,
450            "message": message,
451            "next": runtime_error_next_step(error),
452        }),
453        RuntimeError::Cli(error) => serde_json::json!({
454            "ok": false,
455            "error": cli_error_code(error),
456            "message": error.to_string(),
457            "next": cli_error_next_step(error),
458        }),
459    }
460}
461
462/// Returns a stable machine-readable parse error code.
463const fn cli_error_code(error: &CliError) -> &'static str {
464    match error {
465        CliError::UnknownCommand | CliError::UnknownCommandName { .. } => "unknown_command",
466        CliError::MissingArgument { .. } => "missing_argument",
467        CliError::MissingFlagValue { .. } => "missing_flag_value",
468        CliError::DuplicateFlag { .. } => "duplicate_flag",
469        CliError::UnexpectedArgument { .. } => "unexpected_argument",
470        CliError::UnknownFlag { .. } => "unknown_flag",
471        CliError::UnsupportedFlag { .. } => "unsupported_flag",
472        CliError::UnknownResource { .. } => "unknown_resource",
473        CliError::UnknownStatus(_) => "unknown_status",
474        CliError::UnknownTraceStatus(_) => "unknown_trace_status",
475        CliError::UnknownLogLevel(_) => "unknown_log_level",
476        CliError::InvalidLimit(_) => "invalid_limit",
477        CliError::InvalidMinDuration(_) => "invalid_min_duration",
478        CliError::UnknownPagination => "unknown_pagination",
479        CliError::InvalidActionCursor(_) => "invalid_action_cursor",
480        CliError::InvalidLogCursor(_) => "invalid_log_cursor",
481        CliError::InvalidIssueCursor(_) => "invalid_issue_cursor",
482        CliError::InvalidSupportCursor(_) => "invalid_support_cursor",
483        CliError::UnknownSupportCategory => "unknown_support_category",
484        CliError::InvalidSupportTicketId => "invalid_support_ticket_id",
485        CliError::InvalidSupportRetryKey => "invalid_support_retry_key",
486        CliError::InvalidSupportContextReply => "invalid_support_context_reply",
487        CliError::InvalidSupportContextCommand => "invalid_support_context_command",
488        CliError::InvalidSupportContext => "invalid_support_context",
489        CliError::InvalidInvestigationCommand => "invalid_investigation_command",
490        CliError::InvalidDoctorCommand => "invalid_doctor_command",
491        CliError::InvalidProjectCreateCommand => "invalid_project_create_command",
492        CliError::InvalidProjectsCommand => "invalid_projects_command",
493        CliError::InvalidUsageCommand => "invalid_usage_command",
494        CliError::InvalidNativeDebugCommand | CliError::InvalidNativeDebugIdentity => {
495            "invalid_native_debug_command"
496        }
497        CliError::InvalidSetupSource(_) => "invalid_setup_source",
498    }
499}
500
501/// Returns the next step for a parse error.
502const fn cli_error_next_step(error: &CliError) -> &'static str {
503    match error {
504        CliError::InvalidLimit(_) => "use --limit with a positive whole number",
505        CliError::InvalidMinDuration(_) => "use --min-duration-ms with a non-negative whole number",
506        CliError::UnknownPagination
507        | CliError::InvalidActionCursor(_)
508        | CliError::InvalidLogCursor(_)
509        | CliError::InvalidIssueCursor(_)
510        | CliError::InvalidSupportCursor(_) => {
511            "use --pagination cursor alone for the first page, then use --cursor-time and --cursor-id together from next_cursor"
512        }
513        CliError::UnknownSupportCategory => {
514            "use sdk_install_failure, ingest_failure, auth_failure, project_setup, dashboard_issue, docs_confusion, cli_issue, mobile_issue, billing_question, or other"
515        }
516        CliError::InvalidSupportTicketId => {
517            "use the ticket_id returned by logbrew support create or list"
518        }
519        CliError::InvalidSupportRetryKey => {
520            "use --retry-key with 1 to 128 visible ASCII characters and reuse it only for an exact retry"
521        }
522        CliError::InvalidSupportContextReply => {
523            "use support reply <ticket_id> --context <text> --retry-key <key>"
524        }
525        CliError::InvalidSupportContextCommand => {
526            "use support context <ticket_id> with optional --json"
527        }
528        CliError::InvalidSupportContext => {
529            "use --context with 1 to 4000 characters after trimming whitespace"
530        }
531        CliError::InvalidInvestigationCommand => {
532            "use logbrew investigate issue <issue_id> with optional --json"
533        }
534        CliError::InvalidDoctorCommand => {
535            "use logbrew doctor --project <project_id> with optional --json"
536        }
537        CliError::InvalidProjectCreateCommand => {
538            "use logbrew projects create <name> --ingest-key-file <path> with optional --runtime, --environment, --abandon-retry, and --json"
539        }
540        CliError::InvalidProjectsCommand => {
541            "use logbrew projects with optional --json, or logbrew projects --help"
542        }
543        CliError::InvalidUsageCommand => "use logbrew usage with optional --json",
544        CliError::InvalidNativeDebugCommand => {
545            "use logbrew debug-artifacts upload <path> --project <project_id> --release <release> --environment <environment> --service <service> with optional --expect-image-uuid, --dry-run, and --json"
546        }
547        CliError::InvalidNativeDebugIdentity => {
548            "use a UUID in 8-4-4-4-12 form and architecture arm64, arm64e, or x86_64"
549        }
550        CliError::InvalidSetupSource(_) => "use --source api, cli, or sdk",
551        CliError::MissingArgument { next, .. }
552        | CliError::MissingFlagValue { next, .. }
553        | CliError::DuplicateFlag { next, .. }
554        | CliError::UnexpectedArgument { next, .. }
555        | CliError::UnsupportedFlag { next, .. }
556        | CliError::UnknownFlag { next, .. }
557        | CliError::UnknownResource { next, .. }
558        | CliError::UnknownCommandName { next, .. } => next,
559        CliError::UnknownCommand => "run logbrew --help",
560        CliError::UnknownStatus(_) => ISSUE_STATUS_VALUES_NEXT_STEP,
561        CliError::UnknownTraceStatus(_) => "use --status error or --status ok",
562        CliError::UnknownLogLevel(_) => "use one of info, warning, error, critical",
563    }
564}
565
566/// Returns a stable machine-readable runtime error code.
567const fn runtime_error_code(error: &RuntimeError) -> &'static str {
568    match error {
569        RuntimeError::Cli(error) => cli_error_code(error),
570        RuntimeError::Io(_) => "io_error",
571        RuntimeError::Http(_) => "http_error",
572        RuntimeError::MissingToken => "not_logged_in",
573        RuntimeError::Api { .. } => "api_error",
574        RuntimeError::StatusUnavailable { .. } => "status_unreachable",
575        RuntimeError::Unavailable { .. } => "unavailable",
576        RuntimeError::InvestigationResponseInvalid => "investigation_response_invalid",
577        RuntimeError::NativeDebugArtifactInvalid => "native_debug_artifact_invalid",
578        RuntimeError::NativeDebugResponseInvalid => "native_debug_response_invalid",
579        RuntimeError::NativeDebugVerificationFailed => "native_debug_verification_failed",
580    }
581}
582
583/// Next step for failed `status` reachability checks.
584const STATUS_UNAVAILABLE_NEXT_STEP: &str = "check LOGBREW_API_URL or network";
585
586/// Parsed public API error details.
587#[derive(Debug, Clone, Default, PartialEq, Eq)]
588struct ApiErrorDetails {
589    /// Human-readable backend error message.
590    error: Option<String>,
591    /// Stable backend error code.
592    code: Option<String>,
593    /// Backend-provided recovery step.
594    next: Option<String>,
595}
596
597impl ApiErrorDetails {
598    /// Parses additive backend error fields from an API response body.
599    fn parse(body: &str) -> Self {
600        let Ok(value) = serde_json::from_str::<serde_json::Value>(body) else {
601            return Self::default();
602        };
603
604        Self {
605            error: json_string_field(&value, "error"),
606            code: json_string_field(&value, "code"),
607            next: json_string_field(&value, "next"),
608        }
609    }
610}
611
612/// Extracts a non-empty string field from a JSON object.
613fn json_string_field(value: &serde_json::Value, key: &str) -> Option<String> {
614    value
615        .get(key)
616        .and_then(serde_json::Value::as_str)
617        .map(str::trim)
618        .filter(|value| !value.is_empty())
619        .map(ToOwned::to_owned)
620}
621
622/// Returns a useful next step for runtime errors when one is known.
623fn runtime_error_next_step(error: &RuntimeError) -> Cow<'static, str> {
624    match error {
625        RuntimeError::Api { status, body, .. } => {
626            let api_details = ApiErrorDetails::parse(body);
627            api_next_step(*status, &api_details)
628        }
629        RuntimeError::Cli(_)
630        | RuntimeError::Io(_)
631        | RuntimeError::Http(_)
632        | RuntimeError::MissingToken
633        | RuntimeError::InvestigationResponseInvalid
634        | RuntimeError::NativeDebugArtifactInvalid
635        | RuntimeError::NativeDebugResponseInvalid
636        | RuntimeError::NativeDebugVerificationFailed
637        | RuntimeError::StatusUnavailable { .. }
638        | RuntimeError::Unavailable { .. } => {
639            Cow::Borrowed(fallback_runtime_error_next_step(error))
640        }
641    }
642}
643
644/// Returns the API next step, preferring backend guidance when available.
645fn api_next_step(status: u16, api_details: &ApiErrorDetails) -> Cow<'static, str> {
646    api_details.next.as_ref().map_or_else(
647        || Cow::Borrowed(fallback_api_next_step(status)),
648        |next| Cow::Owned(next.clone()),
649    )
650}
651
652/// Returns the CLI fallback next step for an API status.
653const fn fallback_api_next_step(status: u16) -> &'static str {
654    match status {
655        401 | 403 => "run logbrew login",
656        400 | 422 => "check command arguments or filters",
657        404 => "check the resource id or filters",
658        429 => "retry later",
659        500..=599 => "check LOGBREW_API_URL or retry later",
660        _ => "check command arguments or retry later",
661    }
662}
663
664/// Returns a useful fallback next step for runtime errors when one is known.
665const fn fallback_runtime_error_next_step(error: &RuntimeError) -> &'static str {
666    match error {
667        RuntimeError::Cli(error) => cli_error_next_step(error),
668        RuntimeError::MissingToken => "run logbrew login",
669        RuntimeError::Api { status, .. } => fallback_api_next_step(*status),
670        RuntimeError::StatusUnavailable { .. } | RuntimeError::Http(_) => {
671            STATUS_UNAVAILABLE_NEXT_STEP
672        }
673        RuntimeError::Unavailable { next, .. } => next,
674        RuntimeError::InvestigationResponseInvalid => {
675            "retry the issue investigation; if it repeats, report the public response contract"
676        }
677        RuntimeError::NativeDebugArtifactInvalid => {
678            "provide one validated Apple dSYM, ZIP, or Mach-O object matching every --expect-image-uuid value"
679        }
680        RuntimeError::NativeDebugResponseInvalid => {
681            "retry the native debug-artifact request; if it repeats, report the public response contract"
682        }
683        RuntimeError::NativeDebugVerificationFailed => {
684            "retry exact native debug-artifact lookup before using native symbolication"
685        }
686        RuntimeError::Io(_) => "check local files and permissions",
687    }
688}