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
8fn 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 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 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 ZeroRound,
93 BadLayout(String),
95 BadRoundDir(String),
96 NotMarkdown(String),
97 BadFileName(String),
98 UnknownKind(String),
99 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}