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