laterite 0.12.0

Read, validate and write AGS4 — the geotechnical data transfer format
Documentation
//! One error type for the whole surface.
//!
//! Opaque struct + coarse kind, rather than an enum mirroring the engines'.
//! Every engine error type is free to gain a variant — that is the point of the
//! two tiers — and re-exporting them here would make each of those a breaking
//! change for every consumer of this crate.

use std::fmt;

/// What went wrong, coarsely.
///
/// `#[non_exhaustive]` because this list will grow: a consumer must be able to
/// keep compiling when it does. Match with a `_` arm, or use [`Error::kind_str`]
/// if you are routing on the string.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ErrorKind {
    /// The file could not be read, written, or found.
    Io,
    /// The bytes are not AGS4 — structurally unparseable, not merely invalid.
    NotAgs4,
    /// A dictionary or edition was requested that does not exist or does not parse.
    BadDictionary,
    /// The data could not be written as valid AGS4.
    Emit,
    /// A caller argument was wrong — an unknown group code, a row index out of
    /// range, an encoding label nothing recognises.
    InvalidArgument,
    /// Something the engine reported that this crate does not classify.
    ///
    /// Not dead weight: the engine names its error kinds as strings and is the
    /// single producer of that domain, so it can add one without this crate
    /// changing. Mapping such a token onto whichever existing kind looked
    /// closest would be a confident wrong answer; this is the honest one. It
    /// carries the engine's own message.
    Other,
    // --- Appended, and that is not a style choice ------------------------
    //
    // New variants go at the END of this enum, never in the middle, however
    // much better they would read grouped with their neighbours. A variant's
    // discriminant is its position, so inserting one renumbers every variant
    // after it and breaks any downstream `kind as isize` — `cargo semver-checks`
    // calls that a MAJOR change and is right to. `#[non_exhaustive]` makes
    // APPENDING a variant non-breaking; it does nothing for inserting one.
    // Learned by inserting these two above `Other` and moving it from 5 to 7.
    /// Two files being merged declare different AGS TYPEs for one heading, and
    /// no resolution was chosen.
    ///
    /// Its own kind rather than [`ErrorKind::InvalidArgument`] because the
    /// caller CAN settle it — [`ags4::Merge::on_type_clash`](crate::ags4::Merge::on_type_clash)
    /// widens the column to `X` or promotes it to the greatest `nDP` precision.
    /// The Python, Node and `lat` surfaces draw the same distinction under the
    /// same `type_conflict` token.
    TypeConflict,
    /// Two files being merged declare different UNITs for one heading.
    ///
    /// Separate from [`ErrorKind::TypeConflict`] because it is fatal in every
    /// mode and the two need different fixes: no resolution absorbs a unit
    /// disagreement, since picking one would silently mislabel the other file's
    /// values. Reconcile the `UNIT` row in the sources.
    UnitConflict,
    /// A merge was asked to refuse when no transmission stamp is supplied
    /// ([`ags4::MissingTran::Refuse`](crate::ags4::MissingTran::Refuse)) and
    /// none was.
    ///
    /// Its own kind rather than [`ErrorKind::InvalidArgument`] for the same
    /// reason the two above are: it is a merge refusal, it shares their exit
    /// code, and a caller routing on `kind_str()` should be able to tell which
    /// of merge's three refusals it hit — they need three different fixes.
    MissingTran,
}

impl ErrorKind {
    /// The stable wire token for this kind.
    ///
    /// Shared verbatim with the Python, Node and `lat` surfaces, which is what
    /// makes it worth freezing: a tool that routes on laterite's error strings
    /// gets the same tokens whichever binding produced them. These strings are
    /// part of the public API and will not change under a consumer.
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            ErrorKind::Io => "io",
            ErrorKind::NotAgs4 => "not_ags4",
            ErrorKind::BadDictionary => "bad_dict",
            ErrorKind::Emit => "emit",
            ErrorKind::InvalidArgument => "invalid_argument",
            ErrorKind::TypeConflict => "type_conflict",
            ErrorKind::UnitConflict => "unit_conflict",
            ErrorKind::MissingTran => "missing_tran",
            ErrorKind::Other => "error",
        }
    }

    /// The process exit code `lat` uses for this kind, so a wrapper binary can
    /// exit the same way without restating the mapping.
    #[must_use]
    pub fn exit_code(self) -> i32 {
        match self {
            ErrorKind::Io => 2,
            ErrorKind::NotAgs4 | ErrorKind::BadDictionary | ErrorKind::InvalidArgument => 3,
            ErrorKind::Emit => 4,
            // 6, not 4: `lat merge` reports an unresolved schema conflict with
            // its own code, and this domain is shared verbatim with the other
            // surfaces — a wrapper routing on it must get the same number here.
            ErrorKind::TypeConflict | ErrorKind::UnitConflict | ErrorKind::MissingTran => 6,
            ErrorKind::Other => 1,
        }
    }
}

/// An error from any laterite operation.
///
/// Deliberately a struct with private fields, not an enum. Adding a case to a
/// public enum is a breaking change; adding an [`ErrorKind`] to this is not.
pub struct Error {
    kind: ErrorKind,
    message: String,
    /// The engine error, kept so `{:#}` and an `anyhow`/`eyre` chain render the
    /// underlying detail. It is wrapped in a PRIVATE newtype (see [`Source`]),
    /// so `source()` can be walked and printed but never `downcast_ref` onto an
    /// engine type — which would put that type back in the public API through
    /// the back door.
    source: Option<Source>,
}

struct Source(String);

impl fmt::Debug for Source {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

impl fmt::Display for Source {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.0)
    }
}

impl std::error::Error for Source {}

impl Error {
    pub(crate) fn new(kind: ErrorKind, message: impl Into<String>) -> Error {
        Error {
            kind,
            message: message.into(),
            source: None,
        }
    }

    pub(crate) fn with_source(
        kind: ErrorKind,
        message: impl Into<String>,
        source: impl fmt::Display,
    ) -> Error {
        Error {
            kind,
            message: message.into(),
            source: Some(Source(source.to_string())),
        }
    }

    /// Which coarse category this is.
    #[must_use]
    pub fn kind(&self) -> ErrorKind {
        self.kind
    }

    /// The stable wire token — shorthand for `self.kind().as_str()`.
    #[must_use]
    pub fn kind_str(&self) -> &'static str {
        self.kind.as_str()
    }

    /// The process exit code `lat` would use — shorthand for
    /// `self.kind().exit_code()`.
    #[must_use]
    pub fn exit_code(&self) -> i32 {
        self.kind.exit_code()
    }
}

impl fmt::Display for Error {
    /// `{}` is the message alone; `{:#}` appends the cause.
    ///
    /// Plain `{}` stays terse because that is what a wrapper expects: `anyhow`
    /// and `eyre` walk `source()` themselves and print each link with `{}`, so an
    /// error that appended its own cause would render it twice in every chain.
    ///
    /// `{:#}` exists because the terse form alone is not always enough to act on,
    /// and a caller who is *not* using one of those crates has otherwise no way to
    /// see the cause short of walking `source()` by hand. The field's doc has
    /// claimed `{:#}` did this since the crate was written; it does now.
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.message)?;
        if let Some(source) = self.source.as_ref().filter(|_| f.alternate()) {
            write!(f, ": {source}")?;
        }
        Ok(())
    }
}

impl fmt::Debug for Error {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Error")
            .field("kind", &self.kind)
            .field("message", &self.message)
            .field("source", &self.source)
            .finish()
    }
}

impl std::error::Error for Error {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        self.source
            .as_ref()
            .map(|s| s as &(dyn std::error::Error + 'static))
    }
}