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 file = File::open(path)?;
26 let reader = BufReader::new(file);
27 let mut number = self.number_from_filename(path).unwrap_or(0);
28 let mut title = String::new();
29 let mut status = String::from("Accepted");
30 let mut date = String::new();
31 let mut supersedes: Option<u32> = None;
32 let mut superseded_by: Option<u32> = None;
33
34 for (i, line) in reader.lines().take(200).enumerate() {
35 let line = line?;
36 if i == 0 {
37 if let Some(idx) = line.find(": ") {
38 let head = &line[..idx];
39 if let Some(num_idx) = head.rfind(' ') {
40 if let Ok(n) = head[num_idx + 1..].parse::<u32>() {
41 number = n;
42 }
43 }
44 title = line[idx + 2..].trim().to_string();
45 }
46 }
47 if let Some(stripped) = line.strip_prefix("Title:") {
48 title = stripped.trim().to_string();
49 }
50 if let Some(stripped) = line.strip_prefix("Date:") {
51 date = stripped.trim().to_string();
52 }
53 if let Some(stripped) = line.strip_prefix("Status:") {
54 status = stripped.trim().to_string();
55 }
56 if let Some(stripped) = line.strip_prefix("Supersedes:") {
57 let v = stripped.trim();
58 if let Ok(n) = v.parse::<u32>() {
59 supersedes = Some(n);
60 }
61 }
62 if let Some(stripped) = line.strip_prefix("Superseded-by:") {
63 let v = stripped.trim();
64 if let Ok(n) = v.parse::<u32>() {
65 superseded_by = Some(n);
66 }
67 }
68 }
69
70 if title.is_empty() {
71 title = self
72 .title_from_filename(path)
73 .unwrap_or_else(|| "Untitled".to_string());
74 }
75 if date.is_empty() {
76 date = Local::now().format("%Y-%m-%d").to_string();
77 }
78
79 Ok(AdrMeta {
80 number,
81 title,
82 status,
83 date,
84 supersedes,
85 superseded_by,
86 path: path.to_path_buf(),
87 })
88 }
89
90 fn number_from_filename(&self, path: &Path) -> Option<u32> {
91 let fname = path.file_name()?.to_str()?;
92 let re = Regex::new(r"^(\d{4})-").ok()?;
93 let caps = re.captures(fname)?;
94 caps.get(1)?.as_str().parse::<u32>().ok()
95 }
96
97 fn title_from_filename(&self, path: &Path) -> Option<String> {
98 let fname = path.file_stem()?.to_str()?;
99 let mut parts = fname.splitn(2, '-');
100 parts.next()?;
101 let slug = parts.next().unwrap_or("");
102 if slug.is_empty() {
103 return None;
104 }
105 let title = slug
106 .split('-')
107 .filter(|s| !s.is_empty())
108 .map(|w| {
109 let mut cs = w.chars();
110 match cs.next() {
111 Some(f) => f.to_ascii_uppercase().to_string() + cs.as_str(),
112 None => String::new(),
113 }
114 })
115 .collect::<Vec<_>>()
116 .join(" ");
117 Some(title)
118 }
119}
120
121impl AdrRepository for FsAdrRepository {
122 fn adr_dir(&self) -> &Path {
123 &self.root
124 }
125
126 fn list(&self) -> Result<Vec<AdrMeta>> {
127 let mut res = Vec::new();
128 if !self.root.exists() {
129 return Ok(res);
130 }
131 let re = Regex::new(r"^\d{4}-.*\.md$")
132 .map_err(|e| anyhow!("invalid ADR filename regex: {}", e))?;
133 for entry in fs::read_dir(&self.root)
134 .with_context(|| format!("Reading ADR directory at {}", self.root.display()))?
135 {
136 let entry = entry?;
137 let path = entry.path();
138 if !path.is_file() || path.extension().and_then(OsStr::to_str) != Some("md") {
139 continue;
140 }
141 let fname = path.file_name().and_then(OsStr::to_str).unwrap_or("");
142 if !re.is_match(fname) {
143 continue;
144 }
145 let meta = self.parse_adr_file(&path)?;
146 res.push(meta);
147 }
148 res.sort_by_key(|a| a.number);
149 Ok(res)
150 }
151
152 fn read_string(&self, path: &Path) -> Result<String> {
153 let content = fs::read_to_string(path)?;
154 Ok(content)
155 }
156
157 fn write_string(&self, path: &Path, content: &str) -> Result<()> {
158 if let Some(parent) = path.parent() {
159 fs::create_dir_all(parent)?;
160 }
161 let mut f = File::create(path)?;
162 f.write_all(content.as_bytes())?;
163 Ok(())
164 }
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use tempfile::tempdir;
171
172 #[test]
173 fn test_empty_list_ok() {
174 let dir = tempdir().unwrap();
175 let repo = FsAdrRepository::new(dir.path());
176 let list = repo.list().unwrap();
177 assert!(list.is_empty());
178 }
179
180 #[test]
181 fn test_ignores_non_matching_and_fallbacks() {
182 let dir = tempdir().unwrap();
183 let root = dir.path();
184 std::fs::write(root.join("README.md"), "hello").unwrap();
186 let adr_path = root.join("0007-no-status.md");
188 std::fs::write(&adr_path, "# minimal file\n\nBody\n").unwrap();
189
190 let repo = FsAdrRepository::new(root);
191 let list = repo.list().unwrap();
192 assert_eq!(list.len(), 1);
193 let a = &list[0];
194 assert_eq!(a.number, 7);
195 assert_eq!(a.title, "No Status");
196 assert_eq!(a.status, "Accepted");
198 let today = chrono::Local::now().format("%Y-%m-%d").to_string();
200 assert_eq!(a.date, today);
201 }
202
203 #[test]
204 fn test_parse_fields_from_content() {
205 let dir = tempdir().unwrap();
206 let root = dir.path();
207 let p = root.join("0010-detailed.md");
208 let today = chrono::Local::now().format("%Y-%m-%d").to_string();
209 let content = format!(
210 "# ADR 0010: Header Title\n\nTitle: Overridden Title\nDate: {}\nStatus: Proposed\nSupersedes: 0002\nSuperseded-by: 0011\n\nBody\n",
211 today
212 );
213 std::fs::write(&p, content).unwrap();
214 let repo = FsAdrRepository::new(root);
215 let list = repo.list().unwrap();
216 assert_eq!(list.len(), 1);
217 let a = &list[0];
218 assert_eq!(a.number, 10);
219 assert_eq!(a.title, "Overridden Title");
221 assert_eq!(a.date, today);
222 assert_eq!(a.status, "Proposed");
223 assert_eq!(a.supersedes, Some(2));
224 assert_eq!(a.superseded_by, Some(11));
225 }
226
227 #[test]
228 fn test_untitled_when_empty_slug() {
229 let dir = tempdir().unwrap();
230 let root = dir.path();
231 let p = root.join("0008-.md");
232 std::fs::write(&p, "# No header number or title\n").unwrap();
233 let repo = FsAdrRepository::new(root);
234 let list = repo.list().unwrap();
235 assert_eq!(list.len(), 1);
236 assert_eq!(list[0].title, "Untitled");
237 assert_eq!(list[0].number, 8);
238 }
239}