1use anyhow::{anyhow, Context, Result};
2use chrono::Local;
3use regex::Regex;
4use std::{
5 ffi::OsStr,
6 fs,
7 fs::File,
8 io::{BufRead, BufReader, Write},
9 path::{Path, PathBuf},
10};
11
12use super::AdrRepository;
13use crate::domain::AdrMeta;
14
15pub struct FsAdrRepository {
16 root: PathBuf,
17}
18
19impl FsAdrRepository {
20 pub fn new<P: Into<PathBuf>>(root: P) -> Self {
21 Self { root: root.into() }
22 }
23
24 fn parse_adr_file(&self, path: &Path) -> Result<AdrMeta> {
25 let mut number = self.number_from_filename(path).unwrap_or(0);
26 let mut title = String::new();
27 let mut status = String::from("Accepted");
28 let mut date = String::new();
29 let mut supersedes: Option<u32> = None;
30 let mut superseded_by: Option<u32> = None;
31
32 let raw = fs::read_to_string(path)?;
33 if let Some(stripped) = raw.strip_prefix("---\n") {
35 if let Some(end) = stripped.find("\n---\n") {
36 let fm_block = &stripped[..end];
37 #[derive(serde::Deserialize)]
38 struct FM {
39 title: Option<String>,
40 date: Option<String>,
41 status: Option<String>,
42 number: Option<u32>,
43 supersedes: Option<u32>,
44 superseded_by: Option<u32>,
45 }
46 if let Ok(fm) = serde_yaml::from_str::<FM>(fm_block) {
47 if let Some(n) = fm.number {
48 number = n;
49 }
50 if let Some(t) = fm.title {
51 title = t;
52 }
53 if let Some(d) = fm.date {
54 date = d;
55 }
56 if let Some(s) = fm.status {
57 status = s;
58 }
59 if let Some(su) = fm.supersedes {
60 supersedes = Some(su);
61 }
62 if let Some(sb) = fm.superseded_by {
63 superseded_by = Some(sb);
64 }
65 }
66 }
67 }
68
69 if title.is_empty() || date.is_empty() || status.is_empty() {
70 let reader = BufReader::new(raw.as_bytes());
72 for (i, line) in reader.lines().take(200).enumerate() {
73 let line = line?;
74 if i == 0 {
75 if let Some(idx) = line.find(": ") {
76 let head = &line[..idx];
77 if let Some(num_idx) = head.rfind(' ') {
78 if let Ok(n) = head[num_idx + 1..].parse::<u32>() {
79 number = n;
80 }
81 }
82 title = line[idx + 2..].trim().to_string();
83 }
84 }
85 if let Some(stripped) = line.strip_prefix("Title:") {
86 title = stripped.trim().to_string();
87 }
88 if let Some(stripped) = line.strip_prefix("Date:") {
89 date = stripped.trim().to_string();
90 }
91 if let Some(stripped) = line.strip_prefix("Status:") {
92 status = stripped.trim().to_string();
93 }
94 if let Some(stripped) = line.strip_prefix("Supersedes:") {
95 let v = stripped.trim();
96 if let Ok(n) = v.parse::<u32>() {
97 supersedes = Some(n);
98 }
99 }
100 if let Some(stripped) = line.strip_prefix("Superseded-by:") {
101 let v = stripped.trim();
102 if let Ok(n) = v.parse::<u32>() {
103 superseded_by = Some(n);
104 }
105 }
106 }
107 }
108
109 if title.is_empty() {
110 title = self
111 .title_from_filename(path)
112 .unwrap_or_else(|| "Untitled".to_string());
113 }
114 if date.is_empty() {
115 date = Local::now().format("%Y-%m-%d").to_string();
116 }
117
118 Ok(AdrMeta {
119 number,
120 title,
121 status,
122 date,
123 supersedes,
124 superseded_by,
125 path: path.to_path_buf(),
126 })
127 }
128
129 fn number_from_filename(&self, path: &Path) -> Option<u32> {
130 let fname = path.file_name()?.to_str()?;
131 let re = Regex::new(r"^(\d{4})-").ok()?;
132 let caps = re.captures(fname)?;
133 caps.get(1)?.as_str().parse::<u32>().ok()
134 }
135
136 fn title_from_filename(&self, path: &Path) -> Option<String> {
137 let fname = path.file_stem()?.to_str()?;
138 let mut parts = fname.splitn(2, '-');
139 parts.next()?;
140 let slug = parts.next().unwrap_or("");
141 if slug.is_empty() {
142 return None;
143 }
144 let title = slug
145 .split('-')
146 .filter(|s| !s.is_empty())
147 .map(|w| {
148 let mut cs = w.chars();
149 match cs.next() {
150 Some(f) => f.to_ascii_uppercase().to_string() + cs.as_str(),
151 None => String::new(),
152 }
153 })
154 .collect::<Vec<_>>()
155 .join(" ");
156 Some(title)
157 }
158}
159
160impl AdrRepository for FsAdrRepository {
161 fn adr_dir(&self) -> &Path {
162 &self.root
163 }
164
165 fn list(&self) -> Result<Vec<AdrMeta>> {
166 let mut res = Vec::new();
167 if !self.root.exists() {
168 return Ok(res);
169 }
170 let re = Regex::new(r"^\d{4}-.*\.(md|mdx)$")
171 .map_err(|e| anyhow!("invalid ADR filename regex: {}", e))?;
172 for entry in fs::read_dir(&self.root)
173 .with_context(|| format!("Reading ADR directory at {}", self.root.display()))?
174 {
175 let entry = entry?;
176 let path = entry.path();
177 if !path.is_file() {
178 continue;
179 }
180 let fname = path.file_name().and_then(OsStr::to_str).unwrap_or("");
181 if !re.is_match(fname) {
182 continue;
183 }
184 let meta = self.parse_adr_file(&path)?;
185 res.push(meta);
186 }
187 res.sort_by_key(|a| a.number);
188 Ok(res)
189 }
190
191 fn read_string(&self, path: &Path) -> Result<String> {
192 let content = fs::read_to_string(path)?;
193 Ok(content)
194 }
195
196 fn write_string(&self, path: &Path, content: &str) -> Result<()> {
197 if let Some(parent) = path.parent() {
198 fs::create_dir_all(parent)?;
199 }
200 let mut f = File::create(path)?;
201 f.write_all(content.as_bytes())?;
202 Ok(())
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209 use tempfile::tempdir;
210
211 #[test]
212 fn test_empty_list_ok() {
213 let dir = tempdir().unwrap();
214 let repo = FsAdrRepository::new(dir.path());
215 let list = repo.list().unwrap();
216 assert!(list.is_empty());
217 }
218
219 #[test]
220 fn test_ignores_non_matching_and_fallbacks() {
221 let dir = tempdir().unwrap();
222 let root = dir.path();
223 std::fs::write(root.join("README.md"), "hello").unwrap();
225 let adr_path = root.join("0007-no-status.md");
227 std::fs::write(&adr_path, "# minimal file\n\nBody\n").unwrap();
228
229 let repo = FsAdrRepository::new(root);
230 let list = repo.list().unwrap();
231 assert_eq!(list.len(), 1);
232 let a = &list[0];
233 assert_eq!(a.number, 7);
234 assert_eq!(a.title, "No Status");
235 assert_eq!(a.status, "Accepted");
237 let today = chrono::Local::now().format("%Y-%m-%d").to_string();
239 assert_eq!(a.date, today);
240 }
241
242 #[test]
243 fn test_parse_fields_from_content() {
244 let dir = tempdir().unwrap();
245 let root = dir.path();
246 let p = root.join("0010-detailed.md");
247 let today = chrono::Local::now().format("%Y-%m-%d").to_string();
248 let content = format!(
249 "# ADR 0010: Header Title\n\nTitle: Overridden Title\nDate: {}\nStatus: Proposed\nSupersedes: 0002\nSuperseded-by: 0011\n\nBody\n",
250 today
251 );
252 std::fs::write(&p, content).unwrap();
253 let repo = FsAdrRepository::new(root);
254 let list = repo.list().unwrap();
255 assert_eq!(list.len(), 1);
256 let a = &list[0];
257 assert_eq!(a.number, 10);
258 assert_eq!(a.title, "Overridden Title");
260 assert_eq!(a.date, today);
261 assert_eq!(a.status, "Proposed");
262 assert_eq!(a.supersedes, Some(2));
263 assert_eq!(a.superseded_by, Some(11));
264 }
265
266 #[test]
267 fn test_untitled_when_empty_slug() {
268 let dir = tempdir().unwrap();
269 let root = dir.path();
270 let p = root.join("0008-.md");
271 std::fs::write(&p, "# No header number or title\n").unwrap();
272 let repo = FsAdrRepository::new(root);
273 let list = repo.list().unwrap();
274 assert_eq!(list.len(), 1);
275 assert_eq!(list[0].title, "Untitled");
276 assert_eq!(list[0].number, 8);
277 }
278
279 #[test]
280 fn test_parse_front_matter_only_mdx() {
281 let dir = tempdir().unwrap();
282 let root = dir.path();
283 let p = root.join("0001-front.mdx");
284 let content = "---\n\
285title: Front Matter Title\n\
286date: 2025-01-02\n\
287status: Proposed\n\
288number: 1\n\
289supersedes: 3\n\
290superseded_by: 5\n\
291---\n\nBody\n";
292 std::fs::write(&p, content).unwrap();
293 let repo = FsAdrRepository::new(root);
294 let list = repo.list().unwrap();
295 assert_eq!(list.len(), 1);
296 let a = &list[0];
297 assert_eq!(a.number, 1);
298 assert_eq!(a.title, "Front Matter Title");
299 assert_eq!(a.date, "2025-01-02");
300 assert_eq!(a.status, "Proposed");
301 assert_eq!(a.supersedes, Some(3));
302 assert_eq!(a.superseded_by, Some(5));
303 }
304
305 #[test]
306 fn test_list_includes_mdx_and_md() {
307 let dir = tempdir().unwrap();
308 let root = dir.path();
309 std::fs::write(root.join("0001-a.mdx"), "# ADR 0001: A\n\n").unwrap();
310 std::fs::write(root.join("0002-b.md"), "# ADR 0002: B\n\n").unwrap();
311 let repo = FsAdrRepository::new(root);
312 let list = repo.list().unwrap();
313 assert_eq!(list.len(), 2);
314 assert_eq!(list[0].number, 1);
315 assert_eq!(list[1].number, 2);
316 }
317}