koala-artifact 1.0.4

Reviewer artifact format and sampling verifier.
Documentation
use crate::kind::ReviewerKind;
use std::fmt;
use std::path::{Component, Path, PathBuf};
use std::str::FromStr;

pub const REVIEW_DIR: &str = ".review";

/// Names use lowercase ASCII alphanumerics and dashes. The constraint keeps
/// the on-disk filename round-trippable into `<kind>-<name>.md` without
/// shell-quoting drama.
fn name_is_well_formed(s: &str) -> bool {
    !s.is_empty()
        && s.chars()
            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
        && !s.starts_with('-')
        && !s.ends_with('-')
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArtifactPath {
    pub round: u32,
    pub kind: ReviewerKind,
    pub name: String,
}

impl ArtifactPath {
    pub fn new(round: u32, kind: ReviewerKind, name: impl Into<String>) -> Result<Self, PathError> {
        if round == 0 {
            return Err(PathError::ZeroRound);
        }
        let name = name.into();
        if !name_is_well_formed(&name) {
            return Err(PathError::BadName(name));
        }
        Ok(Self { round, kind, name })
    }

    /// Path relative to the repo root: `.review/round-N/<kind>-<name>.md`.
    pub fn relative(&self) -> PathBuf {
        PathBuf::from(REVIEW_DIR)
            .join(format!("round-{}", self.round))
            .join(format!("{}-{}.md", self.kind, self.name))
    }

    pub fn absolute(&self, repo_root: &Path) -> PathBuf {
        repo_root.join(self.relative())
    }

    /// Parse a path that is *already relative* to the repo root.
    /// Anything that doesn't match `.review/round-N/<kind>-<name>.md` is
    /// rejected; verify must refuse to operate on stray files.
    pub fn parse_relative(rel: &Path) -> Result<Self, PathError> {
        let comps: Vec<&str> = rel
            .components()
            .map(|c| match c {
                Component::Normal(s) => s.to_str().unwrap_or(""),
                _ => "",
            })
            .collect();

        if comps.len() != 3 || comps[0] != REVIEW_DIR {
            return Err(PathError::BadLayout(rel.display().to_string()));
        }
        let round = comps[1]
            .strip_prefix("round-")
            .and_then(|n| n.parse::<u32>().ok())
            .ok_or_else(|| PathError::BadRoundDir(comps[1].to_string()))?;
        if round == 0 {
            return Err(PathError::ZeroRound);
        }
        let file = comps[2]
            .strip_suffix(".md")
            .ok_or_else(|| PathError::NotMarkdown(comps[2].to_string()))?;
        let (kind_str, name) = file
            .split_once('-')
            .ok_or_else(|| PathError::BadFileName(file.to_string()))?;
        let kind = ReviewerKind::from_str(kind_str).map_err(PathError::UnknownKind)?;
        if !name_is_well_formed(name) {
            return Err(PathError::BadName(name.to_string()));
        }
        Ok(Self {
            round,
            kind,
            name: name.to_string(),
        })
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathError {
    /// Round 0 is reserved (rounds are 1-indexed).
    ZeroRound,
    /// Path doesn't have the `.review/round-N/<file>.md` shape.
    BadLayout(String),
    BadRoundDir(String),
    NotMarkdown(String),
    BadFileName(String),
    UnknownKind(String),
    /// Name failed `[a-z0-9-]+` (not starting/ending with `-`).
    BadName(String),
}

impl fmt::Display for PathError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::ZeroRound => write!(f, "round number must be ≥ 1"),
            Self::BadLayout(p) => write!(
                f,
                "path `{p}` is not in the canonical `.review/round-N/<kind>-<name>.md` layout"
            ),
            Self::BadRoundDir(s) => write!(f, "directory `{s}` is not `round-<N>`"),
            Self::NotMarkdown(s) => write!(f, "file `{s}` does not end in `.md`"),
            Self::BadFileName(s) => {
                write!(f, "file `{s}` is not `<kind>-<name>` (missing `-`)")
            }
            Self::UnknownKind(s) => write!(f, "{s}"),
            Self::BadName(s) => write!(
                f,
                "name `{s}` must match [a-z0-9-]+ and not start/end with `-`"
            ),
        }
    }
}

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

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

    #[test]
    fn build_and_parse_round_trip() {
        let p = ArtifactPath::new(1, ReviewerKind::Concept, "no-stale-refs").unwrap();
        let rel = p.relative();
        assert_eq!(
            rel.to_str().unwrap(),
            ".review/round-1/concept-no-stale-refs.md"
        );
        let parsed = ArtifactPath::parse_relative(&rel).unwrap();
        assert_eq!(parsed, p);
    }

    #[test]
    fn rejects_round_zero() {
        assert!(matches!(
            ArtifactPath::new(0, ReviewerKind::Concept, "x"),
            Err(PathError::ZeroRound)
        ));
        assert!(matches!(
            ArtifactPath::parse_relative(Path::new(".review/round-0/concept-x.md")),
            Err(PathError::ZeroRound)
        ));
    }

    #[test]
    fn rejects_uppercase_or_underscore_name() {
        assert!(ArtifactPath::new(1, ReviewerKind::Concept, "Bad_Name").is_err());
        assert!(ArtifactPath::new(1, ReviewerKind::Concept, "-leading").is_err());
        assert!(ArtifactPath::new(1, ReviewerKind::Concept, "trailing-").is_err());
        assert!(ArtifactPath::new(1, ReviewerKind::Concept, "").is_err());
    }

    #[test]
    fn rejects_paths_outside_review_dir() {
        assert!(ArtifactPath::parse_relative(Path::new("notes/concept-x.md")).is_err());
        assert!(ArtifactPath::parse_relative(Path::new(".review/concept-x.md")).is_err());
        assert!(
            ArtifactPath::parse_relative(Path::new(".review/round-1/sub/concept-x.md")).is_err()
        );
        assert!(ArtifactPath::parse_relative(Path::new(".review/round-1/concept-x.txt")).is_err());
        assert!(ArtifactPath::parse_relative(Path::new(".review/round-abc/concept-x.md")).is_err());
        assert!(ArtifactPath::parse_relative(Path::new(".review/round-1/conceptx.md")).is_err());
        assert!(ArtifactPath::parse_relative(Path::new(".review/round-1/foo-x.md")).is_err());
    }
}