reserve-core 0.1.1

Core lookup, catalog, and rate-limiting engine behind the reserve domain finder
Documentation
use std::fmt;
use std::path::PathBuf;

/// @docgen Scripts branch on these codes, so a value never changes meaning within a major version.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(u8)]
pub enum ExitClass {
    Success = 0,
    NothingAvailable = 1,
    Usage = 2,
    Config = 3,
    Network = 4,
    Io = 5,
    Interrupted = 130,
}

impl ExitClass {
    #[must_use]
    pub const fn code(self) -> u8 {
        self as u8
    }
}

impl fmt::Display for ExitClass {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.code())
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ErrorId {
    FileUnreadable,
    CatalogMalformed,
    CatalogEmptySelection,
    GroupUnknown,
    ExtensionInvalid,
    FilterInvalid,
    NameInvalid,
    NameListEmpty,
    BootstrapUnavailable,
    NetworkUnreachable,
    OutputUnwritable,
}

impl ErrorId {
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::FileUnreadable => "file.unreadable",
            Self::CatalogMalformed => "catalog.malformed",
            Self::CatalogEmptySelection => "catalog.empty_selection",
            Self::GroupUnknown => "group.unknown",
            Self::ExtensionInvalid => "extension.invalid",
            Self::FilterInvalid => "filter.invalid",
            Self::NameInvalid => "name.invalid",
            Self::NameListEmpty => "name.list_empty",
            Self::BootstrapUnavailable => "bootstrap.unavailable",
            Self::NetworkUnreachable => "network.unreachable",
            Self::OutputUnwritable => "output.unwritable",
        }
    }
}

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

/// @docgen The binary owns human formatting, so another front end can render failures differently.
#[derive(Debug, thiserror::Error)]
pub enum Error {
    #[error("{path} could not be read")]
    FileUnreadable {
        path: PathBuf,
        #[source]
        source: std::io::Error,
    },

    #[error("the catalog data could not be parsed")]
    CatalogMalformed {
        #[source]
        source: Box<serde_json::Error>,
    },

    #[error("the filters you gave leave no extension to check")]
    CatalogEmptySelection,

    #[error("`{name}` is not a known group")]
    GroupUnknown {
        name: String,
        closest_groups: Vec<String>,
    },

    #[error("`{extension}` is not a usable domain extension")]
    ExtensionInvalid { extension: String },

    #[error("`{value}` is not a usable {setting}")]
    FilterInvalid { setting: String, value: String },

    #[error("`{name}` is not a usable domain name")]
    NameInvalid { name: String, reason: String },

    #[error("no name was given to check")]
    NameListEmpty,

    #[error("the registry server list could not be fetched and no cached copy is usable")]
    BootstrapUnavailable {
        #[source]
        source: Box<dyn std::error::Error + Send + Sync>,
    },

    #[error("the network could not be reached")]
    NetworkUnreachable {
        #[source]
        source: Box<dyn std::error::Error + Send + Sync>,
    },

    #[error("output could not be written to {target}")]
    OutputUnwritable {
        target: String,
        #[source]
        source: std::io::Error,
    },
}

impl Error {
    #[must_use]
    pub const fn id(&self) -> ErrorId {
        match self {
            Self::FileUnreadable { .. } => ErrorId::FileUnreadable,
            Self::CatalogMalformed { .. } => ErrorId::CatalogMalformed,
            Self::CatalogEmptySelection => ErrorId::CatalogEmptySelection,
            Self::GroupUnknown { .. } => ErrorId::GroupUnknown,
            Self::ExtensionInvalid { .. } => ErrorId::ExtensionInvalid,
            Self::FilterInvalid { .. } => ErrorId::FilterInvalid,
            Self::NameInvalid { .. } => ErrorId::NameInvalid,
            Self::NameListEmpty => ErrorId::NameListEmpty,
            Self::BootstrapUnavailable { .. } => ErrorId::BootstrapUnavailable,
            Self::NetworkUnreachable { .. } => ErrorId::NetworkUnreachable,
            Self::OutputUnwritable { .. } => ErrorId::OutputUnwritable,
        }
    }

    #[must_use]
    pub const fn exit_class(&self) -> ExitClass {
        match self {
            Self::FileUnreadable { .. }
            | Self::CatalogMalformed { .. }
            | Self::CatalogEmptySelection
            | Self::GroupUnknown { .. }
            | Self::ExtensionInvalid { .. }
            | Self::FilterInvalid { .. }
            | Self::NameInvalid { .. }
            | Self::NameListEmpty => ExitClass::Usage,
            Self::BootstrapUnavailable { .. } | Self::NetworkUnreachable { .. } => {
                ExitClass::Network
            }
            Self::OutputUnwritable { .. } => ExitClass::Io,
        }
    }

    #[must_use]
    pub fn remedy(&self) -> String {
        match self {
            Self::FileUnreadable { .. } => {
                "Check that the file exists and that you can read it.".to_owned()
            }
            Self::CatalogMalformed { .. } => {
                "The supplied catalog file is not valid. Remove it to use the bundled one."
                    .to_owned()
            }
            Self::CatalogEmptySelection => {
                "Loosen a filter, or run `reserve groups` to see what you can pick.".to_owned()
            }
            Self::GroupUnknown { closest_groups, .. } => {
                if closest_groups.is_empty() {
                    "Run `reserve groups` to see every group.".to_owned()
                } else {
                    format!("Did you mean {}?", closest_groups.join(", "))
                }
            }
            Self::ExtensionInvalid { .. } => {
                "Give the extension without a leading dot, such as `com` or `co.uk`.".to_owned()
            }
            Self::FilterInvalid { .. } => {
                "Run `reserve --help` to see the accepted values.".to_owned()
            }
            Self::NameInvalid { .. } => {
                "Give the name without the extension, such as `example`.".to_owned()
            }
            Self::NameListEmpty => "Pass at least one name to check.".to_owned(),
            Self::BootstrapUnavailable { .. } => {
                "Check your connection, or pass `--source text` to skip the registry list."
                    .to_owned()
            }
            Self::NetworkUnreachable { .. } => "Check your connection and try again.".to_owned(),
            Self::OutputUnwritable { .. } => {
                "Check that the target is writable and has free space.".to_owned()
            }
        }
    }
}

pub type Result<T, E = Error> = std::result::Result<T, E>;

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn exit_codes_are_the_documented_contract() {
        assert_eq!(ExitClass::Success.code(), 0);
        assert_eq!(ExitClass::NothingAvailable.code(), 1);
        assert_eq!(ExitClass::Usage.code(), 2);
        assert_eq!(ExitClass::Network.code(), 4);
        assert_eq!(ExitClass::Io.code(), 5);
        assert_eq!(ExitClass::Interrupted.code(), 130);
    }

    #[test]
    fn every_error_has_an_id_and_a_remedy() {
        let cases = [
            Error::CatalogEmptySelection,
            Error::NameListEmpty,
            Error::ExtensionInvalid {
                extension: "..".to_owned(),
            },
        ];
        for case in cases {
            assert!(!case.id().as_str().is_empty());
            assert!(!case.remedy().is_empty());
        }
    }

    #[test]
    fn usage_errors_map_to_the_usage_exit_class() {
        assert_eq!(Error::NameListEmpty.exit_class(), ExitClass::Usage);
        assert_eq!(Error::CatalogEmptySelection.exit_class(), ExitClass::Usage);
    }

    #[test]
    fn unknown_group_suggests_the_closest_names() {
        let err = Error::GroupUnknown {
            name: "tec".to_owned(),
            closest_groups: vec!["tech".to_owned()],
        };
        assert!(err.remedy().contains("tech"));
    }
}