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 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        // Non-matching files are ignored
185        std::fs::write(root.join("README.md"), "hello").unwrap();
186        // Minimal ADR with only filename, parser should fallback
187        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        // Status defaults to Accepted when missing
197        assert_eq!(a.status, "Accepted");
198        // Date defaults to today when missing
199        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        // Title: line should override header
220        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}