use-go-work 0.0.1

go.work workspace metadata primitives for RustUse
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]

use core::{fmt, str::FromStr};
use std::error::Error;

use use_go_module::GoModuleReplacement;

/// Error returned by Go workspace metadata constructors.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GoWorkError {
    EmptyModulePath,
    InvalidModulePath,
    EmptyLabel,
    UnknownLabel,
}

impl fmt::Display for GoWorkError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::EmptyModulePath => {
                formatter.write_str("Go workspace module path cannot be empty")
            }
            Self::InvalidModulePath => formatter.write_str("invalid Go workspace module path"),
            Self::EmptyLabel => formatter.write_str("go.work metadata label cannot be empty"),
            Self::UnknownLabel => formatter.write_str("unknown go.work metadata label"),
        }
    }
}

impl Error for GoWorkError {}

/// `go.work` config file kind.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum GoWorkConfigFile {
    GoWork,
}

impl GoWorkConfigFile {
    /// Returns the config file name.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        "go.work"
    }
}

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

impl FromStr for GoWorkConfigFile {
    type Err = GoWorkError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match normalized_label(value)?.as_str() {
            "go.work" | "gowork" => Ok(Self::GoWork),
            _ => Err(GoWorkError::UnknownLabel),
        }
    }
}

/// Go workspace layout metadata.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum GoWorkspaceLayout {
    SingleModule,
    MultiModule,
    Monorepo,
    ToolWorkspace,
}

impl GoWorkspaceLayout {
    /// Returns the workspace layout label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::SingleModule => "single-module",
            Self::MultiModule => "multi-module",
            Self::Monorepo => "monorepo",
            Self::ToolWorkspace => "tool-workspace",
        }
    }
}

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

impl FromStr for GoWorkspaceLayout {
    type Err = GoWorkError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        match normalized_label(value)?.as_str() {
            "single-module" | "single_module" | "single module" => Ok(Self::SingleModule),
            "multi-module" | "multi_module" | "multi module" => Ok(Self::MultiModule),
            "monorepo" => Ok(Self::Monorepo),
            "tool-workspace" | "tool_workspace" | "tool workspace" => Ok(Self::ToolWorkspace),
            _ => Err(GoWorkError::UnknownLabel),
        }
    }
}

/// Validated `go.work` module path metadata.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct GoWorkModulePath(String);

impl GoWorkModulePath {
    /// Creates workspace module path metadata.
    ///
    /// # Errors
    ///
    /// Returns [`GoWorkError`] when the path is empty or has obvious whitespace/empty segments.
    pub fn new(value: impl AsRef<str>) -> Result<Self, GoWorkError> {
        let trimmed = value.as_ref().trim();
        if trimmed.is_empty() {
            return Err(GoWorkError::EmptyModulePath);
        }
        if trimmed.chars().any(char::is_whitespace) || trimmed.split('/').any(str::is_empty) {
            return Err(GoWorkError::InvalidModulePath);
        }
        Ok(Self(trimmed.to_string()))
    }

    /// Returns the module path text.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl AsRef<str> for GoWorkModulePath {
    fn as_ref(&self) -> &str {
        self.as_str()
    }
}

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

impl FromStr for GoWorkModulePath {
    type Err = GoWorkError;

    fn from_str(value: &str) -> Result<Self, Self::Err> {
        Self::new(value)
    }
}

impl TryFrom<&str> for GoWorkModulePath {
    type Error = GoWorkError;

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        Self::new(value)
    }
}

/// Lightweight `go.work` file metadata.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct GoWorkFile {
    directives: Vec<GoWorkDirective>,
}

impl GoWorkFile {
    /// Creates empty `go.work` metadata.
    #[must_use]
    pub const fn new() -> Self {
        Self {
            directives: Vec::new(),
        }
    }

    /// Adds a directive and returns the updated metadata.
    #[must_use]
    pub fn with_directive(mut self, directive: GoWorkDirective) -> Self {
        self.directives.push(directive);
        self
    }

    /// Returns the directives.
    #[must_use]
    pub fn directives(&self) -> &[GoWorkDirective] {
        &self.directives
    }
}

/// `go.work` directive metadata.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum GoWorkDirective {
    Use(GoWorkUseDirective),
    Replace(GoWorkReplaceDirective),
}

/// `use` directive metadata.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GoWorkUseDirective {
    module_path: GoWorkModulePath,
}

impl GoWorkUseDirective {
    /// Creates a `use` directive.
    #[must_use]
    pub const fn new(module_path: GoWorkModulePath) -> Self {
        Self { module_path }
    }

    /// Returns the workspace module path.
    #[must_use]
    pub const fn module_path(&self) -> &GoWorkModulePath {
        &self.module_path
    }
}

/// `replace` directive metadata.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GoWorkReplaceDirective {
    replacement: GoModuleReplacement,
}

impl GoWorkReplaceDirective {
    /// Creates a `replace` directive.
    #[must_use]
    pub const fn new(replacement: GoModuleReplacement) -> Self {
        Self { replacement }
    }

    /// Returns the replacement metadata.
    #[must_use]
    pub const fn replacement(&self) -> &GoModuleReplacement {
        &self.replacement
    }
}

fn normalized_label(value: &str) -> Result<String, GoWorkError> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        Err(GoWorkError::EmptyLabel)
    } else {
        Ok(trimmed.to_ascii_lowercase())
    }
}

#[cfg(test)]
mod tests {
    use super::{
        GoWorkConfigFile, GoWorkDirective, GoWorkError, GoWorkFile, GoWorkModulePath,
        GoWorkReplaceDirective, GoWorkUseDirective, GoWorkspaceLayout,
    };
    use use_go_module::{GoModulePath, GoModuleReplacement};

    #[test]
    fn validates_workspace_module_paths() -> Result<(), GoWorkError> {
        let path = GoWorkModulePath::new("./app")?;
        assert_eq!(path.as_str(), "./app");
        assert_eq!(GoWorkModulePath::new(""), Err(GoWorkError::EmptyModulePath));
        assert_eq!(
            GoWorkModulePath::new("./app module"),
            Err(GoWorkError::InvalidModulePath)
        );
        assert_eq!(
            GoWorkModulePath::new("app//module"),
            Err(GoWorkError::InvalidModulePath)
        );
        Ok(())
    }

    #[test]
    fn models_go_work_directives() -> Result<(), Box<dyn std::error::Error>> {
        let use_directive = GoWorkUseDirective::new(GoWorkModulePath::new("./app")?);
        let replacement = GoWorkReplaceDirective::new(GoModuleReplacement::new(
            GoModulePath::new("example.com/app")?,
            GoModulePath::new("./app")?,
        ));
        let file = GoWorkFile::new()
            .with_directive(GoWorkDirective::Use(use_directive))
            .with_directive(GoWorkDirective::Replace(replacement));

        assert_eq!(file.directives().len(), 2);
        Ok(())
    }

    #[test]
    fn parses_config_and_layout_labels() -> Result<(), GoWorkError> {
        assert_eq!(
            "go.work".parse::<GoWorkConfigFile>()?,
            GoWorkConfigFile::GoWork
        );
        assert_eq!(
            "multi module".parse::<GoWorkspaceLayout>()?,
            GoWorkspaceLayout::MultiModule
        );
        assert_eq!(
            GoWorkspaceLayout::ToolWorkspace.to_string(),
            "tool-workspace"
        );
        Ok(())
    }
}