radr/repository/
fs.rs

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        // Try front matter first
34        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            // Fallback scan lines for classic format
71            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        // Non-matching files are ignored
224        std::fs::write(root.join("README.md"), "hello").unwrap();
225        // Minimal ADR with only filename, parser should fallback
226        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        // Status defaults to Accepted when missing
236        assert_eq!(a.status, "Accepted");
237        // Date defaults to today when missing
238        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        // Title: line should override header
259        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}