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