Skip to main content

koala_artifact/
path.rs

1use crate::kind::ReviewerKind;
2use std::fmt;
3use std::path::{Component, Path, PathBuf};
4use std::str::FromStr;
5
6pub const REVIEW_DIR: &str = ".review";
7
8/// Names use lowercase ASCII alphanumerics and dashes. The constraint keeps
9/// the on-disk filename round-trippable into `<kind>-<name>.md` without
10/// shell-quoting drama.
11fn name_is_well_formed(s: &str) -> bool {
12    !s.is_empty()
13        && s.chars()
14            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
15        && !s.starts_with('-')
16        && !s.ends_with('-')
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ArtifactPath {
21    pub round: u32,
22    pub kind: ReviewerKind,
23    pub name: String,
24}
25
26impl ArtifactPath {
27    pub fn new(round: u32, kind: ReviewerKind, name: impl Into<String>) -> Result<Self, PathError> {
28        if round == 0 {
29            return Err(PathError::ZeroRound);
30        }
31        let name = name.into();
32        if !name_is_well_formed(&name) {
33            return Err(PathError::BadName(name));
34        }
35        Ok(Self { round, kind, name })
36    }
37
38    /// Path relative to the repo root: `.review/round-N/<kind>-<name>.md`.
39    pub fn relative(&self) -> PathBuf {
40        PathBuf::from(REVIEW_DIR)
41            .join(format!("round-{}", self.round))
42            .join(format!("{}-{}.md", self.kind, self.name))
43    }
44
45    pub fn absolute(&self, repo_root: &Path) -> PathBuf {
46        repo_root.join(self.relative())
47    }
48
49    /// Parse a path that is *already relative* to the repo root.
50    /// Anything that doesn't match `.review/round-N/<kind>-<name>.md` is
51    /// rejected; verify must refuse to operate on stray files.
52    pub fn parse_relative(rel: &Path) -> Result<Self, PathError> {
53        let comps: Vec<&str> = rel
54            .components()
55            .map(|c| match c {
56                Component::Normal(s) => s.to_str().unwrap_or(""),
57                _ => "",
58            })
59            .collect();
60
61        if comps.len() != 3 || comps[0] != REVIEW_DIR {
62            return Err(PathError::BadLayout(rel.display().to_string()));
63        }
64        let round = comps[1]
65            .strip_prefix("round-")
66            .and_then(|n| n.parse::<u32>().ok())
67            .ok_or_else(|| PathError::BadRoundDir(comps[1].to_string()))?;
68        if round == 0 {
69            return Err(PathError::ZeroRound);
70        }
71        let file = comps[2]
72            .strip_suffix(".md")
73            .ok_or_else(|| PathError::NotMarkdown(comps[2].to_string()))?;
74        let (kind_str, name) = file
75            .split_once('-')
76            .ok_or_else(|| PathError::BadFileName(file.to_string()))?;
77        let kind = ReviewerKind::from_str(kind_str).map_err(PathError::UnknownKind)?;
78        if !name_is_well_formed(name) {
79            return Err(PathError::BadName(name.to_string()));
80        }
81        Ok(Self {
82            round,
83            kind,
84            name: name.to_string(),
85        })
86    }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum PathError {
91    /// Round 0 is reserved (rounds are 1-indexed).
92    ZeroRound,
93    /// Path doesn't have the `.review/round-N/<file>.md` shape.
94    BadLayout(String),
95    BadRoundDir(String),
96    NotMarkdown(String),
97    BadFileName(String),
98    UnknownKind(String),
99    /// Name failed `[a-z0-9-]+` (not starting/ending with `-`).
100    BadName(String),
101}
102
103impl fmt::Display for PathError {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        match self {
106            Self::ZeroRound => write!(f, "round number must be ≥ 1"),
107            Self::BadLayout(p) => write!(
108                f,
109                "path `{p}` is not in the canonical `.review/round-N/<kind>-<name>.md` layout"
110            ),
111            Self::BadRoundDir(s) => write!(f, "directory `{s}` is not `round-<N>`"),
112            Self::NotMarkdown(s) => write!(f, "file `{s}` does not end in `.md`"),
113            Self::BadFileName(s) => {
114                write!(f, "file `{s}` is not `<kind>-<name>` (missing `-`)")
115            }
116            Self::UnknownKind(s) => write!(f, "{s}"),
117            Self::BadName(s) => write!(
118                f,
119                "name `{s}` must match [a-z0-9-]+ and not start/end with `-`"
120            ),
121        }
122    }
123}
124
125impl std::error::Error for PathError {}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn build_and_parse_round_trip() {
133        let p = ArtifactPath::new(1, ReviewerKind::Concept, "no-stale-refs").unwrap();
134        let rel = p.relative();
135        assert_eq!(
136            rel.to_str().unwrap(),
137            ".review/round-1/concept-no-stale-refs.md"
138        );
139        let parsed = ArtifactPath::parse_relative(&rel).unwrap();
140        assert_eq!(parsed, p);
141    }
142
143    #[test]
144    fn rejects_round_zero() {
145        assert!(matches!(
146            ArtifactPath::new(0, ReviewerKind::Concept, "x"),
147            Err(PathError::ZeroRound)
148        ));
149        assert!(matches!(
150            ArtifactPath::parse_relative(Path::new(".review/round-0/concept-x.md")),
151            Err(PathError::ZeroRound)
152        ));
153    }
154
155    #[test]
156    fn rejects_uppercase_or_underscore_name() {
157        assert!(ArtifactPath::new(1, ReviewerKind::Concept, "Bad_Name").is_err());
158        assert!(ArtifactPath::new(1, ReviewerKind::Concept, "-leading").is_err());
159        assert!(ArtifactPath::new(1, ReviewerKind::Concept, "trailing-").is_err());
160        assert!(ArtifactPath::new(1, ReviewerKind::Concept, "").is_err());
161    }
162
163    #[test]
164    fn rejects_paths_outside_review_dir() {
165        assert!(ArtifactPath::parse_relative(Path::new("notes/concept-x.md")).is_err());
166        assert!(ArtifactPath::parse_relative(Path::new(".review/concept-x.md")).is_err());
167        assert!(
168            ArtifactPath::parse_relative(Path::new(".review/round-1/sub/concept-x.md")).is_err()
169        );
170        assert!(ArtifactPath::parse_relative(Path::new(".review/round-1/concept-x.txt")).is_err());
171        assert!(ArtifactPath::parse_relative(Path::new(".review/round-abc/concept-x.md")).is_err());
172        assert!(ArtifactPath::parse_relative(Path::new(".review/round-1/conceptx.md")).is_err());
173        assert!(ArtifactPath::parse_relative(Path::new(".review/round-1/foo-x.md")).is_err());
174    }
175}