seaplane_cli/
error.rs

1use std::{
2    error::Error,
3    io::{self, Write},
4    path::PathBuf,
5    result::Result as StdResult,
6};
7
8use seaplane::{
9    api::ApiErrorKind, error::SeaplaneError, rexports::container_image_ref::ImageReferenceError,
10};
11
12use crate::{
13    log::{log_level, LogLevel},
14    printer::{eprinter, Color},
15};
16
17pub type Result<T> = StdResult<T, CliError>;
18
19/// A trait for adding context to an error that will be printed along with the error. Contexts are
20/// useful for adding things such as hints (i.e. try --help), or additional information such as the
21/// path name on a PermissionDenied error, etc.
22///
23/// **NOTE:** all contexts print *without* a trailing newline. This allows a context to print to
24/// the same line in different formats (colors, etc.). If a trailing newline is required, you
25/// should add it manually.
26pub trait Context {
27    /// A simple context
28    fn context<S: Into<String>>(self, msg: S) -> Self;
29
30    /// A context that is evaluated lazily when called. This is useful if building the context is
31    /// expensive or allocates
32    fn with_context<F, S>(self, f: F) -> Self
33    where
34        F: FnOnce() -> S,
35        S: Into<String>;
36
37    /// A simple context that will color the output
38    ///
39    /// **NOTE:** The color is reset at the end of this context even if there is no trailing
40    /// newline. This allows you to chain multiple contexts on the same line where only part of the
41    /// context is colored.
42    fn color_context<S: Into<String>>(self, color: Color, msg: S) -> Self;
43
44    /// A context that will color the output and that is evaluated lazily when called. This is
45    /// useful if building the context is expensive or allocates
46    ///
47    /// **NOTE:** The color is reset at the end of this context even if there is no trailing
48    /// newline. This allows you to chain multiple contexts on the same line where only part of the
49    /// context is colored.
50    fn with_color_context<F, S>(self, f: F) -> Self
51    where
52        F: FnOnce() -> (Color, S),
53        S: Into<String>;
54}
55
56impl<T> Context for StdResult<T, CliError> {
57    fn context<S: Into<String>>(self, msg: S) -> Self {
58        match self {
59            Ok(t) => Ok(t),
60            Err(cli_err) => Err(cli_err.context(msg)),
61        }
62    }
63    fn color_context<S: Into<String>>(self, color: Color, msg: S) -> Self {
64        match self {
65            Ok(t) => Ok(t),
66            Err(cli_err) => Err(cli_err.color_context(color, msg)),
67        }
68    }
69    fn with_context<F, S>(self, f: F) -> Self
70    where
71        F: FnOnce() -> S,
72        S: Into<String>,
73    {
74        match self {
75            Ok(t) => Ok(t),
76            Err(cli_err) => Err(cli_err.context(f())),
77        }
78    }
79
80    fn with_color_context<F, S>(self, f: F) -> Self
81    where
82        F: FnOnce() -> (Color, S),
83        S: Into<String>,
84    {
85        match self {
86            Ok(t) => Ok(t),
87            Err(cli_err) => {
88                let (color, msg) = f();
89                Err(cli_err.color_context(color, msg))
90            }
91        }
92    }
93}
94
95#[derive(Debug)]
96pub struct ColorString {
97    msg: String,
98    color: Option<Color>,
99}
100
101#[derive(Debug)]
102pub struct CliError {
103    kind: CliErrorKind,
104    context: Vec<ColorString>,
105    status: Option<i32>, // TODO: default to 1
106}
107
108impl CliError {
109    pub fn bail(msg: &'static str) -> Self {
110        Self { kind: CliErrorKind::UnknownWithContext(msg), ..Default::default() }
111    }
112}
113
114impl Context for CliError {
115    fn color_context<S: Into<String>>(mut self, color: Color, msg: S) -> Self {
116        self.context
117            .push(ColorString { msg: msg.into(), color: Some(color) });
118        self
119    }
120
121    fn context<S: Into<String>>(mut self, msg: S) -> Self {
122        self.context
123            .push(ColorString { msg: msg.into(), color: None });
124        self
125    }
126
127    fn with_context<F, S>(mut self, f: F) -> Self
128    where
129        F: FnOnce() -> S,
130        S: Into<String>,
131    {
132        self.context
133            .push(ColorString { msg: f().into(), color: None });
134        self
135    }
136
137    fn with_color_context<F, S>(mut self, f: F) -> Self
138    where
139        F: FnOnce() -> (Color, S),
140        S: Into<String>,
141    {
142        let (color, msg) = f();
143        self.context
144            .push(ColorString { msg: msg.into(), color: Some(color) });
145        self
146    }
147}
148
149impl Default for CliError {
150    fn default() -> Self { Self { kind: CliErrorKind::Unknown, context: Vec::new(), status: None } }
151}
152
153// We have to impl Display so we can use the ? operator...but we don't actually want to use it's
154// pipeline to do any kind of displaying because it doesn't support any sort of coloring. So we
155// handle it manually.
156impl std::fmt::Display for CliError {
157    fn fmt(&self, _f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158        panic!("std::fmt::Display is not actually implemented for CliError by design, use CliError::print instead")
159    }
160}
161
162// Just so we can us the ? operator
163impl Error for CliError {}
164
165macro_rules! impl_err {
166    ($errty:ty, $variant:ident) => {
167        impl From<$errty> for CliError {
168            fn from(e: $errty) -> Self {
169                CliError { kind: CliErrorKind::$variant(e), ..Default::default() }
170            }
171        }
172    };
173}
174
175// These are placeholders until we get around to writing distinct errors for the cases we care
176// about
177impl_err!(base64::DecodeError, Base64Decode);
178impl_err!(serde_json::Error, SerdeJson);
179impl_err!(toml::de::Error, TomlDe);
180impl_err!(toml::ser::Error, TomlSer);
181impl_err!(seaplane::error::SeaplaneError, Seaplane);
182impl_err!(seaplane::rexports::container_image_ref::ImageReferenceError, ImageReference);
183impl_err!(std::string::FromUtf8Error, InvalidUtf8);
184impl_err!(hex::FromHexError, HexDecode);
185impl_err!(std::num::ParseIntError, ParseInt);
186impl_err!(strum::ParseError, StrumParse);
187impl_err!(clap::Error, Clap);
188
189impl From<io::Error> for CliError {
190    fn from(e: io::Error) -> Self {
191        match e.kind() {
192            io::ErrorKind::NotFound => {
193                CliError { kind: CliErrorKind::MissingPath, ..Default::default() }
194            }
195            io::ErrorKind::PermissionDenied => {
196                CliError { kind: CliErrorKind::PermissionDenied, ..Default::default() }
197            }
198            _ => CliError { kind: CliErrorKind::Io(e, None), ..Default::default() },
199        }
200    }
201}
202
203impl From<tempfile::PersistError> for CliError {
204    fn from(e: tempfile::PersistError) -> Self {
205        Self {
206            kind: CliErrorKind::Io(e.error, Some(e.file.path().to_path_buf())),
207            ..Default::default()
208        }
209    }
210}
211
212impl From<tempfile::PathPersistError> for CliError {
213    fn from(e: tempfile::PathPersistError) -> Self {
214        Self { kind: CliErrorKind::Io(e.error, Some(e.path.to_path_buf())), ..Default::default() }
215    }
216}
217
218impl From<CliErrorKind> for CliError {
219    fn from(kind: CliErrorKind) -> Self { CliError { kind, ..Default::default() } }
220}
221
222#[derive(Debug)]
223pub enum CliErrorKind {
224    DuplicateName(String),
225    NoMatchingItem(String),
226    AmbiguousItem(String),
227    Io(io::Error, Option<PathBuf>),
228    SerdeJson(serde_json::Error),
229    Base64Decode(base64::DecodeError),
230    TomlDe(toml::de::Error),
231    TomlSer(toml::ser::Error),
232    HexDecode(hex::FromHexError),
233    UnknownWithContext(&'static str),
234    Seaplane(SeaplaneError),
235    ExistingValue(&'static str),
236    ImageReference(ImageReferenceError),
237    InvalidUtf8(std::string::FromUtf8Error),
238    CliArgNotUsed(&'static str),
239    InvalidCliValue(Option<&'static str>, String),
240    ConflictingArguments(String, String),
241    MissingPath,
242    Unknown,
243    PermissionDenied,
244    MissingApiKey,
245    MultipleAtStdin,
246    InlineFlightHasSpace,
247    InlineFlightMissingImage,
248    InlineFlightInvalidName(String),
249    InlineFlightUnknownItem(String),
250    InlineFlightMissingValue(String),
251    ParseInt(std::num::ParseIntError),
252    StrumParse(strum::ParseError),
253    FlightsInUse(Vec<String>),
254    EndpointInvalidFlight(String),
255    OneOff(String),
256    Clap(clap::Error),
257}
258
259impl CliErrorKind {
260    fn print(&self) {
261        use CliErrorKind::*;
262
263        match self {
264            OneOff(msg) => {
265                cli_eprintln!("{msg}");
266            }
267            FlightsInUse(flights) => {
268                cli_eprintln!("the following Flight Plans are referenced by a Formation Plan and cannot be deleted");
269                for f in flights {
270                    cli_eprintln!(@Yellow, "\t{f}");
271                }
272                cli_eprintln!("");
273                cli_eprint!("(hint: override this check and force delete with '");
274                cli_eprint!(@Yellow, "--force");
275                cli_eprintln!("' which will also remove the Flight Plan from the Formation Plan)");
276            }
277            EndpointInvalidFlight(flight) => {
278                cli_eprint!("Flight Plan '");
279                cli_eprint!(@Red, "{flight}");
280                cli_eprintln!(
281                    "' is referenced in an endpoint but does not exist in the local Plans"
282                );
283            }
284            ConflictingArguments(a, b) => {
285                cli_eprint!("cannot use '");
286                cli_eprint!(@Yellow, "{a}");
287                cli_eprint!("' with '");
288                cli_eprint!(@Yellow, "{b}");
289                cli_eprintln!("'");
290                cli_eprintln!(
291                    "(hint: one or both arguments may have been implied from other flags)"
292                );
293            }
294            Base64Decode(e) => {
295                cli_eprintln!("base64 decode: {e}");
296            }
297            DuplicateName(name) => {
298                cli_eprint!("an item with the name '");
299                cli_eprint!(@Yellow, "{name}");
300                cli_eprintln!("' already exists");
301            }
302            NoMatchingItem(item) => {
303                cli_eprint!("the NAME or ID '");
304                cli_eprint!(@Green, "{item}");
305                cli_eprintln!("' didn't match anything");
306            }
307            AmbiguousItem(item) => {
308                cli_eprint!("the NAME or ID '");
309                cli_eprint!(@Yellow, "{item}");
310                cli_eprintln!("' is ambiguous and matches more than one item");
311            }
312            MissingPath => {
313                cli_eprintln!("missing file or directory");
314            }
315            PermissionDenied => {
316                cli_eprintln!("permission denied when accessing file or directory");
317            }
318            HexDecode(e) => {
319                cli_eprintln!("hex decode: {e}")
320            }
321            ImageReference(e) => {
322                cli_eprintln!("seaplane: {e}")
323            }
324            InvalidUtf8(e) => {
325                cli_eprintln!("invalid UTF-8: {e}")
326            }
327            StrumParse(e) => {
328                cli_eprintln!("string parse error: {e}")
329            }
330            Io(e, Some(path)) => {
331                cli_eprintln!("io: {e}");
332                cli_eprint!("\tpath: ");
333                cli_eprintln!(@Yellow, "{path:?}");
334            }
335            Io(e, None) => {
336                cli_eprintln!("io: {e}");
337            }
338            SerdeJson(e) => {
339                cli_eprintln!("json: {e}")
340            }
341            TomlDe(e) => {
342                cli_eprintln!("toml: {e}")
343            }
344            TomlSer(e) => {
345                cli_eprintln!("toml: {e}")
346            }
347            ParseInt(e) => {
348                cli_eprintln!("parse integer: {e}")
349            }
350            UnknownWithContext(e) => {
351                cli_eprintln!("unknown: {e}")
352            }
353            InvalidCliValue(a, v) => {
354                cli_eprint!("the CLI value '");
355                if let Some(val) = a {
356                    cli_eprint!("--{val}=");
357                    cli_eprint!(@Red, "{v}");
358                } else {
359                    cli_eprint!(@Red, "{v}");
360                }
361                cli_eprintln!("' isn't valid");
362            }
363            CliArgNotUsed(a) => {
364                cli_eprint!("the CLI argument '");
365                cli_eprint!("{a}");
366                cli_eprintln!("' wasn't used but is required in this context");
367            }
368            Unknown => {
369                cli_eprintln!("unknown")
370            }
371            MissingApiKey => {
372                cli_eprintln!("no API key was found or provided")
373            }
374            MultipleAtStdin => {
375                cli_eprint!("more than one '");
376                cli_print!(@Yellow, "@-");
377                cli_println!("' values were provided and only one is allowed");
378            }
379            Seaplane(e) => match e {
380                SeaplaneError::ApiResponse(ae) => {
381                    cli_eprintln!("{ae}");
382                    if ae.kind == ApiErrorKind::BadRequest
383                        && ae.message.contains("`force` flag was not set")
384                    {
385                        cli_eprint!("(hint: set the force parameter with '");
386                        cli_eprint!(@Yellow, "--force");
387                        cli_eprintln!("')");
388                    }
389                }
390                _ => {
391                    cli_eprintln!("Seaplane API: {e}")
392                }
393            },
394            ExistingValue(value) => {
395                cli_eprintln!("{value} already exists");
396            }
397            InlineFlightUnknownItem(item) => {
398                cli_eprintln!("{item} is not a valid INLINE-FLIGHT-SPEC item (valid keys are: name, image, maximum, minimum, api-permission, architecture)");
399            }
400            InlineFlightInvalidName(name) => {
401                cli_eprintln!("'{name}' is not a valid Flight Plan name");
402            }
403            InlineFlightHasSpace => {
404                cli_eprintln!("INLINE-FLIGHT-SPEC contains a space ' ' which isn't allowed.");
405            }
406            InlineFlightMissingImage => {
407                cli_eprintln!(
408                    "INLINE-FLIGHT-SPEC missing image=... key and value which is required"
409                );
410            }
411            InlineFlightMissingValue(key) => {
412                cli_eprintln!("INLINE-FLIGHT-SPEC missing a value for the key {key}");
413            }
414            Clap(e) => {
415                cli_eprintln!("{e}")
416            }
417        }
418    }
419
420    pub fn into_err(self) -> CliError { CliError { kind: self, ..Default::default() } }
421
422    #[cfg(test)]
423    pub fn is_parse_int(&self) -> bool { matches!(self, Self::ParseInt(_)) }
424    #[cfg(test)]
425    pub fn is_strum_parse(&self) -> bool { matches!(self, Self::StrumParse(_)) }
426}
427
428// Impl PartialEq manually so we can just match on kind, and not the associated data
429impl PartialEq<Self> for CliErrorKind {
430    fn eq(&self, rhs: &Self) -> bool {
431        use CliErrorKind::*;
432
433        match self {
434            OneOff(_) => matches!(rhs, OneOff(_)),
435            EndpointInvalidFlight(_) => matches!(rhs, EndpointInvalidFlight(_)),
436            AmbiguousItem(_) => matches!(rhs, AmbiguousItem(_)),
437            Io(_, _) => matches!(rhs, Io(_, _)),
438            DuplicateName(_) => matches!(rhs, DuplicateName(_)),
439            MissingApiKey => matches!(rhs, MissingApiKey),
440            MissingPath => matches!(rhs, MissingPath),
441            NoMatchingItem(_) => matches!(rhs, NoMatchingItem(_)),
442            PermissionDenied => matches!(rhs, PermissionDenied),
443            MultipleAtStdin => matches!(rhs, MultipleAtStdin),
444            Seaplane(_) => matches!(rhs, Seaplane(_)),
445            SerdeJson(_) => matches!(rhs, SerdeJson(_)),
446            TomlSer(_) => matches!(rhs, TomlSer(_)),
447            TomlDe(_) => matches!(rhs, TomlDe(_)),
448            Unknown => matches!(rhs, Unknown),
449            UnknownWithContext(_) => matches!(rhs, UnknownWithContext(_)),
450            ExistingValue(_) => matches!(rhs, ExistingValue(_)),
451            ImageReference(_) => matches!(rhs, ImageReference(_)),
452            CliArgNotUsed(_) => matches!(rhs, CliArgNotUsed(_)),
453            InvalidCliValue(_, _) => matches!(rhs, InvalidCliValue(_, _)),
454            StrumParse(_) => matches!(rhs, StrumParse(_)),
455            Base64Decode(_) => matches!(rhs, Base64Decode(_)),
456            InvalidUtf8(_) => matches!(rhs, InvalidUtf8(_)),
457            HexDecode(_) => matches!(rhs, HexDecode(_)),
458            ConflictingArguments(_, _) => matches!(rhs, ConflictingArguments(_, _)),
459            InlineFlightUnknownItem(_) => matches!(rhs, InlineFlightUnknownItem(_)),
460            InlineFlightInvalidName(_) => matches!(rhs, InlineFlightInvalidName(_)),
461            InlineFlightHasSpace => matches!(rhs, InlineFlightHasSpace),
462            InlineFlightMissingImage => matches!(rhs, InlineFlightMissingImage),
463            InlineFlightMissingValue(_) => matches!(rhs, InlineFlightMissingValue(_)),
464            ParseInt(_) => matches!(rhs, ParseInt(_)),
465            FlightsInUse(_) => matches!(rhs, FlightsInUse(_)),
466            Clap(_) => matches!(rhs, Clap(_)),
467        }
468    }
469}
470
471impl CliError {
472    /// Essentially destructure the cli_*! macros which actually also reduces the branches
473    pub fn print(&self) {
474        if log_level() <= &LogLevel::Error {
475            // Scope for acquiring Mutex on global printer
476            {
477                let mut ptr = eprinter();
478                ptr.set_color(Color::Red);
479                let _ = write!(ptr, "error: ");
480                ptr.reset();
481            }
482
483            // This function will try to reacquire the mutex
484            self.kind.print();
485
486            // Reacquire mutex lock
487            let mut ptr = eprinter();
488            for ColorString { color, msg } in &self.context {
489                if let Some(c) = color {
490                    ptr.set_color(*c);
491                }
492                let _ = write!(ptr, "{msg}");
493                ptr.reset();
494            }
495        }
496    }
497
498    pub fn exit(&self) -> ! {
499        self.print();
500        // TODO: solidify what should happen if an error with self.fatal = false is called here...
501        std::process::exit(self.status.unwrap_or(1))
502    }
503
504    pub fn kind(&self) -> &CliErrorKind { &self.kind }
505}