use-vue 0.0.1

Vue component and directive metadata primitives for RustUse
Documentation
#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]

use core::fmt;
use std::error::Error;
use use_js_identifier::{JsIdentifier, JsIdentifierError};

/// Validated Vue component name metadata.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct VueComponentName(String);

impl VueComponentName {
    /// Creates a lightly validated Vue component name.
    ///
    /// # Errors
    ///
    /// Returns [`VueNameError`] when `input` is empty, whitespace-bearing, or not component-shaped.
    pub fn new(input: &str) -> Result<Self, VueNameError> {
        let trimmed = input.trim();
        if trimmed.is_empty() {
            return Err(VueNameError::Empty);
        }
        if trimmed.chars().any(char::is_whitespace) {
            return Err(VueNameError::ContainsWhitespace);
        }
        if trimmed.contains('-') {
            if trimmed.split('-').any(str::is_empty) {
                return Err(VueNameError::InvalidKebabName);
            }
            return Ok(Self(trimmed.to_string()));
        }

        let identifier = JsIdentifier::new(trimmed).map_err(VueNameError::Identifier)?;
        if !identifier
            .as_str()
            .chars()
            .next()
            .is_some_and(|character| character.is_ascii_uppercase())
        {
            return Err(VueNameError::NotComponentName);
        }
        Ok(Self(identifier.as_str().to_string()))
    }

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

/// Vue file-kind metadata.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum VueFileKind {
    Component,
    Composable,
    Store,
    Page,
    Layout,
}

/// Vue API style labels.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum VueApiStyle {
    Options,
    Composition,
    ScriptSetup,
}

impl VueApiStyle {
    /// Returns the API style label.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Options => "options",
            Self::Composition => "composition",
            Self::ScriptSetup => "script-setup",
        }
    }
}

/// Validated Vue directive name metadata without the leading `v-`.
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct VueDirectiveName(String);

impl VueDirectiveName {
    /// Creates a directive name, accepting an optional leading `v-`.
    ///
    /// # Errors
    ///
    /// Returns [`VueNameError`] when `input` is empty or contains whitespace.
    pub fn new(input: &str) -> Result<Self, VueNameError> {
        let trimmed_input = input.trim();
        let trimmed = trimmed_input.strip_prefix("v-").unwrap_or(trimmed_input);
        if trimmed.is_empty() {
            return Err(VueNameError::Empty);
        }
        if trimmed.chars().any(char::is_whitespace) {
            return Err(VueNameError::ContainsWhitespace);
        }
        Ok(Self(trimmed.to_string()))
    }

    /// Returns the directive name without `v-`.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

/// Error returned when Vue name metadata is invalid.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum VueNameError {
    Empty,
    ContainsWhitespace,
    Identifier(JsIdentifierError),
    NotComponentName,
    InvalidKebabName,
}

impl fmt::Display for VueNameError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Empty => formatter.write_str("Vue metadata name cannot be empty"),
            Self::ContainsWhitespace => {
                formatter.write_str("Vue metadata name cannot contain whitespace")
            }
            Self::Identifier(error) => write!(formatter, "invalid JavaScript identifier: {error}"),
            Self::NotComponentName => formatter
                .write_str("Vue component name must be PascalCase-shaped or kebab-case-shaped"),
            Self::InvalidKebabName => {
                formatter.write_str("Vue kebab-case name contains an empty segment")
            }
        }
    }
}

impl Error for VueNameError {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        match self {
            Self::Identifier(error) => Some(error),
            Self::Empty
            | Self::ContainsWhitespace
            | Self::NotComponentName
            | Self::InvalidKebabName => None,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{VueApiStyle, VueComponentName, VueDirectiveName, VueNameError};

    #[test]
    fn validates_component_names() -> Result<(), VueNameError> {
        assert_eq!(VueComponentName::new("UserCard")?.as_str(), "UserCard");
        assert_eq!(VueComponentName::new("user-card")?.as_str(), "user-card");
        assert_eq!(
            VueComponentName::new("user--card"),
            Err(VueNameError::InvalidKebabName)
        );
        Ok(())
    }

    #[test]
    fn validates_directive_names() -> Result<(), VueNameError> {
        let directive = VueDirectiveName::new("v-focus")?;
        assert_eq!(directive.as_str(), "focus");
        assert_eq!(VueApiStyle::ScriptSetup.as_str(), "script-setup");
        Ok(())
    }
}