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    /// Log level is unsupported.
84    #[error("unknown log level: {0}")]
85    UnknownLogLevel(String),
86    /// Row limit is malformed.
87    #[error("invalid limit: {0}")]
88    InvalidLimit(String),
89    /// Project setup source is malformed.
90    #[error("invalid setup source: {0}")]
91    InvalidSetupSource(String),
92}
93
94/// Runtime error for command execution.
95#[derive(Debug, thiserror::Error)]
96pub enum RuntimeError {
97    /// Command-line parsing failed.
98    #[error(transparent)]
99    Cli(#[from] CliError),
100    /// Filesystem or process I/O failed.
101    #[error(transparent)]
102    Io(#[from] std::io::Error),
103    /// HTTP request failed.
104    #[error(transparent)]
105    Http(#[from] reqwest::Error),
106    /// Auth token was missing for an authenticated API call.
107    #[error("not logged in: run logbrew login")]
108    MissingToken,
109    /// API returned a non-success status.
110    #[error("api returned status {status}: {body}")]
111    Api {
112        /// HTTP status code.
113        status: u16,
114        /// Response body.
115        body: String,
116        /// Stable machine-readable auth source key.
117        auth_source: &'static str,
118        /// Concise human auth label.
119        auth_label: &'static str,
120    },
121    /// Status check could not prove API reachability.
122    #[error("LogBrew API unreachable: {message}")]
123    StatusUnavailable {
124        /// Base API URL checked by the CLI.
125        api_url: String,
126        /// Optional HTTP status code returned by `/health`.
127        status_code: Option<u16>,
128        /// Optional response body returned by `/health`.
129        body: Option<String>,
130        /// Whether a local credential is configured.
131        authenticated: bool,
132        /// Stable machine-readable auth source key.
133        auth_source: &'static str,
134        /// Concise human auth label.
135        auth_label: &'static str,
136        /// Human-readable reachability reason.
137        message: String,
138    },
139    /// Command is recognized but not available yet.
140    #[error("{message}")]
141    Unavailable {
142        /// Human-readable unavailable reason.
143        message: &'static str,
144        /// Suggested fallback action.
145        next: &'static str,
146    },
147}
148
149/// Writes a command-line parsing error for humans or agents.
150///
151/// # Errors
152///
153/// Returns an I/O error if writing to the output stream fails.
154pub fn write_cli_error<W: std::io::Write>(
155    error: &CliError,
156    json: bool,
157    output: &mut W,
158) -> Result<(), std::io::Error> {
159    if json {
160        let body = serde_json::json!({
161            "ok": false,
162            "error": cli_error_code(error),
163            "message": error.to_string(),
164            "next": cli_error_next_step(error),
165        });
166        writeln!(output, "{body}")
167    } else {
168        writeln!(output, "{error}")?;
169        writeln!(output, "Next: {}", cli_error_next_step(error))
170    }
171}
172
173/// Writes a runtime error for humans or agents.
174///
175/// # Errors
176///
177/// Returns an I/O error if writing to the output stream fails.
178pub fn write_runtime_error<W: std::io::Write>(
179    error: &RuntimeError,
180    json: bool,
181    output: &mut W,
182) -> Result<(), std::io::Error> {
183    if json {
184        let body = runtime_error_json(error);
185        writeln!(output, "{body}")
186    } else {
187        write_human_runtime_error(error, output)
188    }
189}
190
191/// Writes a runtime error for human readers.
192fn write_human_runtime_error<W: std::io::Write>(
193    error: &RuntimeError,
194    output: &mut W,
195) -> Result<(), std::io::Error> {
196    match error {
197        RuntimeError::StatusUnavailable {
198            api_url,
199            status_code,
200            body,
201            auth_label,
202            message,
203            ..
204        } => {
205            writeln!(output, "LogBrew API unreachable.")?;
206            writeln!(output, "API: {api_url}")?;
207            writeln!(output, "Auth: {auth_label}")?;
208            if let Some(status_code) = status_code {
209                writeln!(output, "Status: {status_code}")?;
210            }
211            if let Some(body) = body.as_ref().filter(|body| !body.is_empty()) {
212                writeln!(output, "Body: {body}")?;
213            } else {
214                writeln!(output, "Reason: {message}")?;
215            }
216            writeln!(output, "Next: {STATUS_UNAVAILABLE_NEXT_STEP}")?;
217            Ok(())
218        }
219        RuntimeError::Api {
220            status,
221            body,
222            auth_label,
223            ..
224        } => {
225            let api_details = ApiErrorDetails::parse(body);
226            writeln!(output, "{error}")?;
227            if let Some(code) = api_details.code.as_deref() {
228                writeln!(output, "Code: {code}")?;
229            }
230            writeln!(output, "Auth: {auth_label}")?;
231            writeln!(output, "Next: {}", api_next_step(*status, &api_details))
232        }
233        RuntimeError::Cli(_)
234        | RuntimeError::Io(_)
235        | RuntimeError::Http(_)
236        | RuntimeError::MissingToken
237        | RuntimeError::Unavailable { .. } => {
238            writeln!(output, "{error}")?;
239            writeln!(output, "Next: {}", runtime_error_next_step(error))?;
240            Ok(())
241        }
242    }
243}
244
245/// Builds a JSON runtime error body for agents.
246fn runtime_error_json(error: &RuntimeError) -> serde_json::Value {
247    match error {
248        RuntimeError::MissingToken
249        | RuntimeError::Unavailable { .. }
250        | RuntimeError::Io(_)
251        | RuntimeError::Http(_) => serde_json::json!({
252            "ok": false,
253            "error": runtime_error_code(error),
254            "message": error.to_string(),
255            "next": runtime_error_next_step(error),
256        }),
257        RuntimeError::Api {
258            status,
259            body,
260            auth_source,
261            ..
262        } => {
263            let api_details = ApiErrorDetails::parse(body);
264            let next = api_next_step(*status, &api_details);
265            serde_json::json!({
266                "ok": false,
267                "error": runtime_error_code(error),
268                "message": error.to_string(),
269                "status": status,
270                "body": body,
271                "api_error": api_details.error.as_deref(),
272                "api_code": api_details.code.as_deref(),
273                "api_next": api_details.next.as_deref(),
274                "auth_source": auth_source,
275                "next": next,
276            })
277        }
278        RuntimeError::StatusUnavailable {
279            api_url,
280            status_code,
281            body,
282            authenticated,
283            auth_source,
284            message,
285            ..
286        } => serde_json::json!({
287            "ok": false,
288            "error": runtime_error_code(error),
289            "status": "unreachable",
290            "status_code": status_code,
291            "body": body,
292            "api_url": api_url,
293            "authenticated": authenticated,
294            "auth_source": auth_source,
295            "message": message,
296            "next": runtime_error_next_step(error),
297        }),
298        RuntimeError::Cli(error) => serde_json::json!({
299            "ok": false,
300            "error": cli_error_code(error),
301            "message": error.to_string(),
302            "next": cli_error_next_step(error),
303        }),
304    }
305}
306
307/// Returns a stable machine-readable parse error code.
308const fn cli_error_code(error: &CliError) -> &'static str {
309    match error {
310        CliError::UnknownCommand | CliError::UnknownCommandName { .. } => "unknown_command",
311        CliError::MissingArgument { .. } => "missing_argument",
312        CliError::MissingFlagValue { .. } => "missing_flag_value",
313        CliError::DuplicateFlag { .. } => "duplicate_flag",
314        CliError::UnexpectedArgument { .. } => "unexpected_argument",
315        CliError::UnknownFlag { .. } => "unknown_flag",
316        CliError::UnsupportedFlag { .. } => "unsupported_flag",
317        CliError::UnknownResource { .. } => "unknown_resource",
318        CliError::UnknownStatus(_) => "unknown_status",
319        CliError::UnknownLogLevel(_) => "unknown_log_level",
320        CliError::InvalidLimit(_) => "invalid_limit",
321        CliError::InvalidSetupSource(_) => "invalid_setup_source",
322    }
323}
324
325/// Returns the next step for a parse error.
326const fn cli_error_next_step(error: &CliError) -> &'static str {
327    match error {
328        CliError::InvalidLimit(_) => "use --limit with a positive whole number",
329        CliError::InvalidSetupSource(_) => "use --source api, cli, or sdk",
330        CliError::MissingArgument { next, .. }
331        | CliError::MissingFlagValue { next, .. }
332        | CliError::DuplicateFlag { next, .. }
333        | CliError::UnexpectedArgument { next, .. }
334        | CliError::UnsupportedFlag { next, .. }
335        | CliError::UnknownFlag { next, .. }
336        | CliError::UnknownResource { next, .. }
337        | CliError::UnknownCommandName { next, .. } => next,
338        CliError::UnknownCommand => "run logbrew --help",
339        CliError::UnknownStatus(_) => ISSUE_STATUS_VALUES_NEXT_STEP,
340        CliError::UnknownLogLevel(_) => "use one of info, warning, error, critical",
341    }
342}
343
344/// Returns a stable machine-readable runtime error code.
345const fn runtime_error_code(error: &RuntimeError) -> &'static str {
346    match error {
347        RuntimeError::Cli(error) => cli_error_code(error),
348        RuntimeError::Io(_) => "io_error",
349        RuntimeError::Http(_) => "http_error",
350        RuntimeError::MissingToken => "not_logged_in",
351        RuntimeError::Api { .. } => "api_error",
352        RuntimeError::StatusUnavailable { .. } => "status_unreachable",
353        RuntimeError::Unavailable { .. } => "unavailable",
354    }
355}
356
357/// Next step for failed `status` reachability checks.
358const STATUS_UNAVAILABLE_NEXT_STEP: &str = "check LOGBREW_API_URL or network";
359
360/// Parsed public API error details.
361#[derive(Debug, Clone, Default, PartialEq, Eq)]
362struct ApiErrorDetails {
363    /// Human-readable backend error message.
364    error: Option<String>,
365    /// Stable backend error code.
366    code: Option<String>,
367    /// Backend-provided recovery step.
368    next: Option<String>,
369}
370
371impl ApiErrorDetails {
372    /// Parses additive backend error fields from an API response body.
373    fn parse(body: &str) -> Self {
374        let Ok(value) = serde_json::from_str::<serde_json::Value>(body) else {
375            return Self::default();
376        };
377
378        Self {
379            error: json_string_field(&value, "error"),
380            code: json_string_field(&value, "code"),
381            next: json_string_field(&value, "next"),
382        }
383    }
384}
385
386/// Extracts a non-empty string field from a JSON object.
387fn json_string_field(value: &serde_json::Value, key: &str) -> Option<String> {
388    value
389        .get(key)
390        .and_then(serde_json::Value::as_str)
391        .map(str::trim)
392        .filter(|value| !value.is_empty())
393        .map(ToOwned::to_owned)
394}
395
396/// Returns a useful next step for runtime errors when one is known.
397fn runtime_error_next_step(error: &RuntimeError) -> Cow<'static, str> {
398    match error {
399        RuntimeError::Api { status, body, .. } => {
400            let api_details = ApiErrorDetails::parse(body);
401            api_next_step(*status, &api_details)
402        }
403        RuntimeError::Cli(_)
404        | RuntimeError::Io(_)
405        | RuntimeError::Http(_)
406        | RuntimeError::MissingToken
407        | RuntimeError::StatusUnavailable { .. }
408        | RuntimeError::Unavailable { .. } => {
409            Cow::Borrowed(fallback_runtime_error_next_step(error))
410        }
411    }
412}
413
414/// Returns the API next step, preferring backend guidance when available.
415fn api_next_step(status: u16, api_details: &ApiErrorDetails) -> Cow<'static, str> {
416    api_details.next.as_ref().map_or_else(
417        || Cow::Borrowed(fallback_api_next_step(status)),
418        |next| Cow::Owned(next.clone()),
419    )
420}
421
422/// Returns the CLI fallback next step for an API status.
423const fn fallback_api_next_step(status: u16) -> &'static str {
424    match status {
425        401 | 403 => "run logbrew login",
426        400 | 422 => "check command arguments or filters",
427        404 => "check the resource id or filters",
428        429 => "retry later",
429        500..=599 => "check LOGBREW_API_URL or retry later",
430        _ => "check command arguments or retry later",
431    }
432}
433
434/// Returns a useful fallback next step for runtime errors when one is known.
435const fn fallback_runtime_error_next_step(error: &RuntimeError) -> &'static str {
436    match error {
437        RuntimeError::Cli(error) => cli_error_next_step(error),
438        RuntimeError::MissingToken => "run logbrew login",
439        RuntimeError::Api { status, .. } => fallback_api_next_step(*status),
440        RuntimeError::StatusUnavailable { .. } | RuntimeError::Http(_) => {
441            STATUS_UNAVAILABLE_NEXT_STEP
442        }
443        RuntimeError::Unavailable { next, .. } => next,
444        RuntimeError::Io(_) => "check local files and permissions",
445    }
446}