Skip to main content

tix/
records.rs

1//! Reusable frontmatter-record store.
2//!
3//! A **record store** is a directory of Markdown files whose YAML frontmatter
4//! carries the record's fields and whose body is free text. The milestone
5//! registry was the first of these; boards (`docs/boards/`) and cycles
6//! (`docs/cycles/`) follow the same shape, so the read/parse/rename/delete
7//! mechanics live here once rather than three times.
8//!
9//! Two rules are load-bearing:
10//!
11//! * **Reading never creates the directory.** A store whose only record is
12//!   implicit (the seeded `Default` cycle, the default board) needs no
13//!   directory at all, and a read that created one would litter a checkout
14//!   the user never asked to configure.
15//! * **Identity is the frontmatter `name`, not the file name.** A file may be
16//!   renamed freely as long as the `name` field follows; `write` picks a
17//!   filename by convention, but `get` answers by `name`.
18
19use std::path::{Path, PathBuf};
20
21use serde::Serialize;
22use serde::de::DeserializeOwned;
23
24use crate::error::{Result, TixError};
25
26/// The trait a record type must implement so the store can address it by name.
27pub trait HasName {
28    /// The record's identity, as stored in its frontmatter `name` field.
29    fn name(&self) -> &str;
30}
31
32/// One record read from disk: its identity, its file, its parsed frontmatter
33/// and its raw body.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct RecordFile<T> {
36    /// The frontmatter `name`.
37    pub name: String,
38    /// The file's name within the store directory.
39    pub filename: String,
40    /// The parsed frontmatter.
41    pub value: T,
42    /// The body after the frontmatter delimiter (verbatim, minus the single
43    /// structural blank line).
44    pub body: String,
45}
46
47/// A directory of frontmatter record files.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct RecordStore {
50    dir: PathBuf,
51}
52
53impl RecordStore {
54    /// Bind the store to `dir`; the directory is not touched until a write.
55    pub fn new(dir: impl Into<PathBuf>) -> Self {
56        Self { dir: dir.into() }
57    }
58
59    /// The directory backing this store.
60    pub fn dir(&self) -> &Path {
61        &self.dir
62    }
63
64    /// Whether the directory exists. Reading does not create it.
65    pub fn exists(&self) -> bool {
66        self.dir.is_dir()
67    }
68
69    /// Every parseable record in the store, sorted by filename.
70    ///
71    /// A missing directory is an empty store, not an error, and files whose
72    /// frontmatter is unparseable or carries no `name` are skipped.
73    pub fn list<T: DeserializeOwned + HasName>(&self) -> Result<Vec<RecordFile<T>>> {
74        Ok(self.list_checked()?.0)
75    }
76
77    /// Like [`RecordStore::list`], additionally returning the filenames that
78    /// were skipped — unparseable files and files whose frontmatter `name` is
79    /// missing or blank. Callers that must *report* a broken file use this.
80    pub fn list_checked<T: DeserializeOwned + HasName>(
81        &self,
82    ) -> Result<(Vec<RecordFile<T>>, Vec<String>)> {
83        if !self.exists() {
84            return Ok((Vec::new(), Vec::new()));
85        }
86        let mut records = Vec::new();
87        let mut skipped = Vec::new();
88        for entry in std::fs::read_dir(&self.dir)? {
89            let Ok(entry) = entry else { continue };
90            if entry.path().extension().and_then(|e| e.to_str()) != Some("md") {
91                continue;
92            }
93            let Some(filename) = entry.file_name().to_str().map(str::to_string) else {
94                continue;
95            };
96            let contents = std::fs::read_to_string(entry.path())?;
97            match parse_record::<T>(&contents) {
98                Some((value, body)) if !value.name().trim().is_empty() => {
99                    records.push(RecordFile {
100                        name: value.name().to_string(),
101                        filename,
102                        value,
103                        body: body.to_string(),
104                    })
105                }
106                _ => skipped.push(filename),
107            }
108        }
109        records.sort_by(|a, b| a.filename.cmp(&b.filename));
110        skipped.sort();
111        Ok((records, skipped))
112    }
113
114    /// Look a record up by its frontmatter `name`.
115    pub fn get<T: DeserializeOwned + HasName>(&self, name: &str) -> Result<RecordFile<T>> {
116        let (records, _) = self.list_checked::<T>()?;
117        records
118            .into_iter()
119            .find(|record| record.name == name)
120            .ok_or_else(|| TixError::NotFound(format!("record `{name}`")))
121    }
122
123    /// Write a new record file, refusing to overwrite an existing one.
124    ///
125    /// Creates the store directory on demand — the lazy rule: only a write
126    /// makes a directory exist.
127    pub fn write<T: Serialize + HasName>(
128        &self,
129        filename: &str,
130        value: &T,
131        body: Option<&str>,
132    ) -> Result<PathBuf> {
133        let path = self.dir.join(filename);
134        if path.exists() {
135            return Err(TixError::InvalidInput(format!(
136                "`{}` already exists",
137                path.display()
138            )));
139        }
140        self.overwrite(filename, value, body)
141    }
142
143    /// Write a record file, replacing any existing file of the same name.
144    pub fn overwrite<T: Serialize + HasName>(
145        &self,
146        filename: &str,
147        value: &T,
148        body: Option<&str>,
149    ) -> Result<PathBuf> {
150        if filename.trim().is_empty() {
151            return Err(TixError::InvalidInput(
152                "record filename must not be blank".into(),
153            ));
154        }
155        std::fs::create_dir_all(&self.dir)?;
156        let path = self.dir.join(filename);
157        let frontmatter = serde_yaml::to_string(value)
158            .map_err(|e| TixError::Parse(format!("cannot serialize record frontmatter: {e}")))?;
159        let contents = format!("---\n{frontmatter}---\n\n{}", render_body(body));
160        std::fs::write(&path, contents)?;
161        Ok(path)
162    }
163
164    /// Rename a record file in place. The caller is responsible for keeping the
165    /// frontmatter `name` in step (see [`RecordStore::overwrite`]).
166    pub fn rename_file(&self, old_filename: &str, new_filename: &str) -> Result<PathBuf> {
167        let old = self.dir.join(old_filename);
168        let new = self.dir.join(new_filename);
169        if !old.is_file() {
170            return Err(TixError::NotFound(format!("`{}`", old.display())));
171        }
172        if new.exists() {
173            return Err(TixError::InvalidInput(format!(
174                "`{}` already exists",
175                new.display()
176            )));
177        }
178        std::fs::rename(&old, &new)?;
179        Ok(new)
180    }
181
182    /// Remove a record file.
183    pub fn delete_file(&self, filename: &str) -> Result<()> {
184        let path = self.dir.join(filename);
185        if !path.is_file() {
186            return Err(TixError::NotFound(format!("`{}`", path.display())));
187        }
188        std::fs::remove_file(path)?;
189        Ok(())
190    }
191
192    /// The body of an existing record file, when it exists and parses. Used by
193    /// callers that rewrite frontmatter to keep a hand-written body intact.
194    pub fn body_of(&self, filename: &str) -> Option<String> {
195        let contents = std::fs::read_to_string(self.dir.join(filename)).ok()?;
196        let (_frontmatter, body) = split_frontmatter(&contents)?;
197        Some(body.to_string())
198    }
199}
200
201/// Split `contents` into its frontmatter text and the body (everything after
202/// the closing `---` delimiter line, with the single structural blank line
203/// consumed), reusing the same delimiter rules as
204/// [`crate::model::Issue::parse`].
205pub fn split_frontmatter(contents: &str) -> Option<(&str, &str)> {
206    let rest = contents.strip_prefix("---\n")?;
207    let mut current = rest;
208    let mut fm_len = 0usize;
209    loop {
210        let line_end = current.find('\n').map(|i| i + 1).unwrap_or(current.len());
211        let content = current[..line_end]
212            .strip_suffix('\n')
213            .unwrap_or(&current[..line_end]);
214        if content == "---" {
215            break;
216        }
217        fm_len += line_end;
218        current = &current[line_end..];
219        if current.is_empty() {
220            return None;
221        }
222    }
223    let mut body = &rest[fm_len..];
224    body = body.strip_prefix("---\n").unwrap_or(body);
225    if let Some(after) = body.strip_prefix('\n') {
226        body = after;
227    }
228    Some((&rest[..fm_len], body))
229}
230
231/// Render a record body: the trimmed text plus a single trailing newline, or
232/// an empty string when there is nothing to store.
233fn render_body(body: Option<&str>) -> String {
234    match body {
235        Some(text) if !text.trim().is_empty() => format!("{}\n", text.trim_end()),
236        _ => String::new(),
237    }
238}
239
240/// Parse a record file into its frontmatter value and body suffix. `None` when
241/// the file has no valid frontmatter block or its YAML does not parse.
242fn parse_record<T: DeserializeOwned>(contents: &str) -> Option<(T, &str)> {
243    let (frontmatter, body) = split_frontmatter(contents)?;
244    let value: T = serde_yaml::from_str(frontmatter).ok()?;
245    Some((value, body))
246}
247
248#[cfg(test)]
249mod tests {
250    use serde::{Deserialize, Serialize};
251    use tempfile::TempDir;
252
253    #[allow(clippy::wildcard_imports)]
254    use super::*;
255
256    #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
257    #[serde(default, deny_unknown_fields)]
258    struct Sample {
259        name: String,
260        count: u32,
261    }
262
263    impl HasName for Sample {
264        fn name(&self) -> &str {
265            &self.name
266        }
267    }
268
269    #[test]
270    fn record_round_trips_and_reading_does_not_create_the_directory() {
271        let tmp = TempDir::new().unwrap();
272        let dir = tmp.path().join("docs").join("samples");
273        let store = RecordStore::new(&dir);
274
275        assert!(!store.exists());
276        assert_eq!(
277            store.list::<Sample>().unwrap(),
278            [] as [crate::records::RecordFile<crate::records::tests::Sample>; 0]
279        );
280        assert!(!dir.exists(), "reading must not create the store directory");
281
282        let record = Sample {
283            name: "Alpha".into(),
284            count: 3,
285        };
286        let path = store.write("alpha.md", &record, Some("a body")).unwrap();
287        assert!(dir.is_dir(), "a write creates the directory");
288        assert_eq!(path.file_name().unwrap(), "alpha.md");
289        let raw = std::fs::read_to_string(&path).unwrap();
290        assert!(raw.starts_with("---\n"), "{raw}");
291        assert!(raw.contains("name: Alpha"), "{raw}");
292        assert!(
293            raw.ends_with("a body\n"),
294            "body has one trailing newline: {raw}"
295        );
296
297        let listed = store.list::<Sample>().unwrap();
298        assert_eq!(listed.len(), 1);
299        assert_eq!(listed[0].name, "Alpha");
300        assert_eq!(listed[0].filename, "alpha.md");
301        assert_eq!(listed[0].value, record);
302        assert_eq!(listed[0].body, "a body\n");
303        assert_eq!(store.get::<Sample>("Alpha").unwrap().value, record);
304
305        // A second write of the same file is refused; `overwrite` replaces it.
306        let err = store.write("alpha.md", &record, None).unwrap_err();
307        assert!(matches!(err, TixError::InvalidInput(_)), "{err:?}");
308        let mut changed = record.clone();
309        changed.count = 9;
310        store.overwrite("alpha.md", &changed, None).unwrap();
311        assert_eq!(store.get::<Sample>("Alpha").unwrap().value.count, 9);
312
313        // Identity is the frontmatter name, not the filename.
314        store.rename_file("alpha.md", "renamed.md").unwrap();
315        assert_eq!(store.get::<Sample>("Alpha").unwrap().filename, "renamed.md");
316        store.delete_file("renamed.md").unwrap();
317        assert_eq!(
318            store.list::<Sample>().unwrap(),
319            [] as [crate::records::RecordFile<crate::records::tests::Sample>; 0]
320        );
321    }
322
323    #[test]
324    fn list_checked_reports_a_record_without_a_name() {
325        let tmp = TempDir::new().unwrap();
326        let dir = tmp.path().join("samples");
327        std::fs::create_dir_all(&dir).unwrap();
328        std::fs::write(
329            dir.join("good.md"),
330            "---\nname: Good\ncount: 1\n---\n\nbody\n",
331        )
332        .unwrap();
333        std::fs::write(dir.join("nameless.md"), "---\ncount: 2\n---\n\nbody\n").unwrap();
334        std::fs::write(dir.join("broken.md"), "not a record\n").unwrap();
335        std::fs::write(dir.join("blank-name.md"), "---\nname: \"\"\n---\n\nbody\n").unwrap();
336
337        let store = RecordStore::new(&dir);
338        let (records, skipped) = store.list_checked::<Sample>().unwrap();
339        assert_eq!(records.len(), 1);
340        assert_eq!(records[0].name, "Good");
341        assert_eq!(
342            skipped,
343            vec![
344                "blank-name.md".to_string(),
345                "broken.md".into(),
346                "nameless.md".into()
347            ],
348            "every unreadable file is reported, not silently dropped"
349        );
350        // `list` skips them too.
351        assert_eq!(store.list::<Sample>().unwrap().len(), 1);
352        assert!(matches!(
353            store.get::<Sample>("nope").unwrap_err(),
354            TixError::NotFound(_)
355        ));
356    }
357
358    #[test]
359    fn get_finds_a_record_whose_name_differs_from_its_filename() {
360        let tmp = TempDir::new().unwrap();
361        let dir = tmp.path().join("samples");
362        std::fs::create_dir_all(&dir).unwrap();
363        std::fs::write(dir.join("weird.md"), "---\nname: Real Name\n---\n\n").unwrap();
364        let store = RecordStore::new(&dir);
365        assert_eq!(
366            store.get::<Sample>("Real Name").unwrap().filename,
367            "weird.md"
368        );
369    }
370}