use crate::kind::ReviewerKind;
use std::fmt;
use std::path::{Component, Path, PathBuf};
use std::str::FromStr;
pub const REVIEW_DIR: &str = ".review";
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 })
}
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())
}
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 {
ZeroRound,
BadLayout(String),
BadRoundDir(String),
NotMarkdown(String),
BadFileName(String),
UnknownKind(String),
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());
}
}