markdown-compiler 0.1.0

Strict Markdown content validation, asset resolution, and revision identities for Maincopy
Documentation
use std::fmt;

use serde::{Deserialize, Serialize};
use thiserror::Error;

#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(transparent)]
pub struct LogicalAssetPath(String);

impl LogicalAssetPath {
    pub fn parse(value: &str) -> Result<Self, LogicalTreePathError> {
        validate_portable_path(value)?;
        let mut components = value.split('/');
        if components.next() != Some("assets") || components.next().is_none() {
            return Err(LogicalTreePathError::WrongAssetNamespace);
        }
        Ok(Self(value.to_owned()))
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

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

#[derive(Clone, Copy, Debug, Deserialize, Eq, Error, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum LogicalTreePathError {
    #[error("logical path must not be empty")]
    Empty,
    #[error("logical path must be relative")]
    Absolute,
    #[error("logical path contains an unsupported component")]
    UnsupportedComponent,
    #[error("logical path contains an encoded or platform-specific separator")]
    EncodedTraversal,
    #[error("logical path exceeds its byte limit")]
    TooLong,
    #[error("logical asset path must start with assets/ and name an entry")]
    WrongAssetNamespace,
}

fn validate_portable_path(value: &str) -> Result<(), LogicalTreePathError> {
    if value.is_empty() {
        return Err(LogicalTreePathError::Empty);
    }
    if value.starts_with('/') || value.starts_with('\\') {
        return Err(LogicalTreePathError::Absolute);
    }
    if value.contains(['%', '\\']) {
        return Err(LogicalTreePathError::EncodedTraversal);
    }
    if !value.split('/').all(is_portable_component) {
        return Err(LogicalTreePathError::UnsupportedComponent);
    }
    Ok(())
}

fn is_portable_component(component: &str) -> bool {
    !component.is_empty()
        && component != "."
        && component != ".."
        && component
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
}