1use std::path::{Path, PathBuf};
20
21use serde::Serialize;
22use serde::de::DeserializeOwned;
23
24use crate::error::{Result, TixError};
25
26pub trait HasName {
28 fn name(&self) -> &str;
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct RecordFile<T> {
36 pub name: String,
38 pub filename: String,
40 pub value: T,
42 pub body: String,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct RecordStore {
50 dir: PathBuf,
51}
52
53impl RecordStore {
54 pub fn new(dir: impl Into<PathBuf>) -> Self {
56 Self { dir: dir.into() }
57 }
58
59 pub fn dir(&self) -> &Path {
61 &self.dir
62 }
63
64 pub fn exists(&self) -> bool {
66 self.dir.is_dir()
67 }
68
69 pub fn list<T: DeserializeOwned + HasName>(&self) -> Result<Vec<RecordFile<T>>> {
74 Ok(self.list_checked()?.0)
75 }
76
77 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 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 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 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 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 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 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
201pub 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(¤t[..line_end]);
214 if content == "---" {
215 break;
216 }
217 fm_len += line_end;
218 current = ¤t[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
231fn 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
240fn 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 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 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 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}