Skip to main content

logbrew_cli/
error.rs

1//! CLI error types and output rendering.
2
3use crate::ISSUE_STATUS_VALUES_NEXT_STEP;
4
5/// CLI parsing error.
6#[derive(Debug, thiserror::Error, PartialEq, Eq)]
7pub enum CliError {
8    /// Command is missing or unsupported.
9    #[error("unknown or missing command")]
10    UnknownCommand,
11    /// Command name is unsupported.
12    #[error("unknown command: {command}")]
13    UnknownCommandName {
14        /// Unsupported command name.
15        command: String,
16        /// Suggested next step.
17        next: &'static str,
18    },
19    /// Required command argument is missing.
20    #[error("missing argument: {argument}")]
21    MissingArgument {
22        /// Missing argument name.
23        argument: &'static str,
24        /// Argument-specific next step.
25        next: &'static str,
26    },
27    /// Required flag value is missing.
28    #[error("missing value for {flag}")]
29    MissingFlagValue {
30        /// Flag missing a value.
31        flag: &'static str,
32        /// Flag-specific next step.
33        next: &'static str,
34    },
35    /// Flag is present more than once.
36    #[error("duplicate flag: {flag}")]
37    DuplicateFlag {
38        /// Duplicate flag value.
39        flag: &'static str,
40        /// Flag-specific next step.
41        next: &'static str,
42    },
43    /// Positional argument is unsupported for the selected command.
44    #[error("unexpected argument for {command}: {argument}")]
45    UnexpectedArgument {
46        /// Unexpected argument value.
47        argument: String,
48        /// Command name.
49        command: &'static str,
50        /// Command-specific next step.
51        next: &'static str,
52    },
53    /// Flag is unknown for the selected command.
54    #[error("unknown flag: {flag}")]
55    UnknownFlag {
56        /// Unknown flag value.
57        flag: String,
58        /// Command-specific next step.
59        next: &'static str,
60    },
61    /// Flag is known globally but unsupported for the selected command.
62    #[error("unsupported flag for {command}: {flag}")]
63    UnsupportedFlag {
64        /// Unsupported flag value.
65        flag: String,
66        /// Command name.
67        command: &'static str,
68        /// Command-specific next step.
69        next: &'static str,
70    },
71    /// Resource is unsupported for the selected command.
72    #[error("unknown resource: {resource}")]
73    UnknownResource {
74        /// Unsupported resource value.
75        resource: String,
76        /// Command-specific next step.
77        next: &'static str,
78    },
79    /// Issue status is unsupported.
80    #[error("unknown issue status: {0}")]
81    UnknownStatus(String),
82    /// Log level is unsupported.
83    #[error("unknown log level: {0}")]
84    UnknownLogLevel(String),
85    /// Row limit is malformed.
86    #[error("invalid limit: {0}")]
87    InvalidLimit(String),
88}
89
90/// Runtime error for command execution.
91#[derive(Debug, thiserror::Error)]
92pub enum RuntimeError {
93    /// Command-line parsing failed.
94    #[error(transparent)]
95    Cli(#[from] CliError),
96    /// Filesystem or process I/O failed.
97    #[error(transparent)]
98    Io(#[from] std::io::Error),
99    /// HTTP request failed.
100    #[error(transparent)]
101    Http(#[from] reqwest::Error),
102    /// Auth token was missing for an authenticated API call.
103    #[error("not logged in: run logbrew login")]
104    MissingToken,
105    /// API returned a non-success status.
106    #[error("api returned status {status}: {body}")]
107    Api {
108        /// HTTP status code.
109        status: u16,
110        /// Response body.
111        body: String,
112        /// Stable machine-readable auth source key.
113        auth_source: &'static str,
114        /// Concise human auth label.
115        auth_label: &'static str,
116    },
117    /// Status check could not prove API reachability.
118    #[error("LogBrew API unreachable: {message}")]
119    StatusUnavailable {
120        /// Base API URL checked by the CLI.
121        api_url: String,
122        /// Optional HTTP status code returned by `/health`.
123        status_code: Option<u16>,
124        /// Optional response body returned by `/health`.
125        body: Option<String>,
126        /// Whether a local credential is configured.
127        authenticated: bool,
128        /// Stable machine-readable auth source key.
129        auth_source: &'static str,
130        /// Concise human auth label.
131        auth_label: &'static str,
132        /// Human-readable reachability reason.
133        message: String,
134    },
135    /// Command is recognized but not available yet.
136    #[error("{message}")]
137    Unavailable {
138        /// Human-readable unavailable reason.
139        message: &'static str,
140        /// Suggested fallback action.
141        next: &'static str,
142    },
143}
144
145/// Writes a command-line parsing error for humans or agents.
146///
147/// # Errors
148///
149/// Returns an I/O error if writing to the output stream fails.
150pub fn write_cli_error<W: std::io::Write>(
151    error: &CliError,
152    json: bool,
153    output: &mut W,
154) -> Result<(), std::io::Error> {
155    if json {
156        let body = serde_json::json!({
157            "ok": false,
158            "error": cli_error_code(error),
159            "message": error.to_string(),
160            "next": cli_error_next_step(error),
161        });
162        writeln!(output, "{body}")
163    } else {
164        writeln!(output, "{error}")?;
165        writeln!(output, "Next: {}", cli_error_next_step(error))
166    }
167}
168
169/// Writes a runtime error for humans or agents.
170///
171/// # Errors
172///
173/// Returns an I/O error if writing to the output stream fails.
174pub fn write_runtime_error<W: std::io::Write>(
175    error: &RuntimeError,
176    json: bool,
177    output: &mut W,
178) -> Result<(), std::io::Error> {
179    if !json {
180        if let RuntimeError::StatusUnavailable {
181            api_url,
182            status_code,
183            body,
184            auth_label,
185            message,
186            ..
187        } = error
188        {
189            writeln!(output, "LogBrew API unreachable.")?;
190            writeln!(output, "API: {api_url}")?;
191            writeln!(output, "Auth: {auth_label}")?;
192            if let Some(status_code) = status_code {
193                writeln!(output, "Status: {status_code}")?;
194            }
195            if let Some(body) = body.as_ref().filter(|body| !body.is_empty()) {
196                writeln!(output, "Body: {body}")?;
197            } else {
198                writeln!(output, "Reason: {message}")?;
199            }
200            writeln!(output, "Next: {STATUS_UNAVAILABLE_NEXT_STEP}")?;
201            return Ok(());
202        }
203        writeln!(output, "{error}")?;
204        if let RuntimeError::Api { auth_label, .. } = error {
205            writeln!(output, "Auth: {auth_label}")?;
206        }
207        writeln!(output, "Next: {}", runtime_error_next_step(error))?;
208        return Ok(());
209    }
210
211    let body = match error {
212        RuntimeError::MissingToken
213        | RuntimeError::Unavailable { .. }
214        | RuntimeError::Io(_)
215        | RuntimeError::Http(_) => serde_json::json!({
216            "ok": false,
217            "error": runtime_error_code(error),
218            "message": error.to_string(),
219            "next": runtime_error_next_step(error),
220        }),
221        RuntimeError::Api {
222            status,
223            body,
224            auth_source,
225            ..
226        } => serde_json::json!({
227            "ok": false,
228            "error": runtime_error_code(error),
229            "message": error.to_string(),
230            "status": status,
231            "body": body,
232            "auth_source": auth_source,
233            "next": runtime_error_next_step(error),
234        }),
235        RuntimeError::StatusUnavailable {
236            api_url,
237            status_code,
238            body,
239            authenticated,
240            auth_source,
241            message,
242            ..
243        } => serde_json::json!({
244            "ok": false,
245            "error": runtime_error_code(error),
246            "status": "unreachable",
247            "status_code": status_code,
248            "body": body,
249            "api_url": api_url,
250            "authenticated": authenticated,
251            "auth_source": auth_source,
252            "message": message,
253            "next": runtime_error_next_step(error),
254        }),
255        RuntimeError::Cli(error) => serde_json::json!({
256            "ok": false,
257            "error": cli_error_code(error),
258            "message": error.to_string(),
259            "next": cli_error_next_step(error),
260        }),
261    };
262    writeln!(output, "{body}")
263}
264
265/// Returns a stable machine-readable parse error code.
266const fn cli_error_code(error: &CliError) -> &'static str {
267    match error {
268        CliError::UnknownCommand | CliError::UnknownCommandName { .. } => "unknown_command",
269        CliError::MissingArgument { .. } => "missing_argument",
270        CliError::MissingFlagValue { .. } => "missing_flag_value",
271        CliError::DuplicateFlag { .. } => "duplicate_flag",
272        CliError::UnexpectedArgument { .. } => "unexpected_argument",
273        CliError::UnknownFlag { .. } => "unknown_flag",
274        CliError::UnsupportedFlag { .. } => "unsupported_flag",
275        CliError::UnknownResource { .. } => "unknown_resource",
276        CliError::UnknownStatus(_) => "unknown_status",
277        CliError::UnknownLogLevel(_) => "unknown_log_level",
278        CliError::InvalidLimit(_) => "invalid_limit",
279    }
280}
281
282/// Returns the next step for a parse error.
283const fn cli_error_next_step(error: &CliError) -> &'static str {
284    match error {
285        CliError::InvalidLimit(_) => "use --limit with a positive whole number",
286        CliError::MissingArgument { next, .. }
287        | CliError::MissingFlagValue { next, .. }
288        | CliError::DuplicateFlag { next, .. }
289        | CliError::UnexpectedArgument { next, .. }
290        | CliError::UnsupportedFlag { next, .. }
291        | CliError::UnknownFlag { next, .. }
292        | CliError::UnknownResource { next, .. }
293        | CliError::UnknownCommandName { next, .. } => next,
294        CliError::UnknownCommand => "run logbrew --help",
295        CliError::UnknownStatus(_) => ISSUE_STATUS_VALUES_NEXT_STEP,
296        CliError::UnknownLogLevel(_) => "use one of trace, debug, info, warn, error, fatal",
297    }
298}
299
300/// Returns a stable machine-readable runtime error code.
301const fn runtime_error_code(error: &RuntimeError) -> &'static str {
302    match error {
303        RuntimeError::Cli(error) => cli_error_code(error),
304        RuntimeError::Io(_) => "io_error",
305        RuntimeError::Http(_) => "http_error",
306        RuntimeError::MissingToken => "not_logged_in",
307        RuntimeError::Api { .. } => "api_error",
308        RuntimeError::StatusUnavailable { .. } => "status_unreachable",
309        RuntimeError::Unavailable { .. } => "unavailable",
310    }
311}
312
313/// Next step for failed `status` reachability checks.
314const STATUS_UNAVAILABLE_NEXT_STEP: &str = "check LOGBREW_API_URL or network";
315
316/// Returns a useful next step for runtime errors when one is known.
317const fn runtime_error_next_step(error: &RuntimeError) -> &'static str {
318    match error {
319        RuntimeError::Cli(error) => cli_error_next_step(error),
320        RuntimeError::MissingToken
321        | RuntimeError::Api {
322            status: 401 | 403, ..
323        } => "run logbrew login",
324        RuntimeError::Api {
325            status: 400 | 422, ..
326        } => "check command arguments or filters",
327        RuntimeError::Api { status: 404, .. } => "check the resource id or filters",
328        RuntimeError::Api { status: 429, .. } => "retry later",
329        RuntimeError::Api {
330            status: 500..=599, ..
331        } => "check LOGBREW_API_URL or retry later",
332        RuntimeError::Api { .. } => "check command arguments or retry later",
333        RuntimeError::StatusUnavailable { .. } | RuntimeError::Http(_) => {
334            STATUS_UNAVAILABLE_NEXT_STEP
335        }
336        RuntimeError::Unavailable { next, .. } => next,
337        RuntimeError::Io(_) => "check local files and permissions",
338    }
339}