use std::path::{Path, PathBuf};
use serde::Serialize;
use serde::de::DeserializeOwned;
use crate::error::{Result, TixError};
pub trait HasName {
fn name(&self) -> &str;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecordFile<T> {
pub name: String,
pub filename: String,
pub value: T,
pub body: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecordStore {
dir: PathBuf,
}
impl RecordStore {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self { dir: dir.into() }
}
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn exists(&self) -> bool {
self.dir.is_dir()
}
pub fn list<T: DeserializeOwned + HasName>(&self) -> Result<Vec<RecordFile<T>>> {
Ok(self.list_checked()?.0)
}
pub fn list_checked<T: DeserializeOwned + HasName>(
&self,
) -> Result<(Vec<RecordFile<T>>, Vec<String>)> {
if !self.exists() {
return Ok((Vec::new(), Vec::new()));
}
let mut records = Vec::new();
let mut skipped = Vec::new();
for entry in std::fs::read_dir(&self.dir)? {
let Ok(entry) = entry else { continue };
if entry.path().extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let Some(filename) = entry.file_name().to_str().map(str::to_string) else {
continue;
};
let contents = std::fs::read_to_string(entry.path())?;
match parse_record::<T>(&contents) {
Some((value, body)) if !value.name().trim().is_empty() => {
records.push(RecordFile {
name: value.name().to_string(),
filename,
value,
body: body.to_string(),
})
}
_ => skipped.push(filename),
}
}
records.sort_by(|a, b| a.filename.cmp(&b.filename));
skipped.sort();
Ok((records, skipped))
}
pub fn get<T: DeserializeOwned + HasName>(&self, name: &str) -> Result<RecordFile<T>> {
let (records, _) = self.list_checked::<T>()?;
records
.into_iter()
.find(|record| record.name == name)
.ok_or_else(|| TixError::NotFound(format!("record `{name}`")))
}
pub fn write<T: Serialize + HasName>(
&self,
filename: &str,
value: &T,
body: Option<&str>,
) -> Result<PathBuf> {
let path = self.dir.join(filename);
if path.exists() {
return Err(TixError::InvalidInput(format!(
"`{}` already exists",
path.display()
)));
}
self.overwrite(filename, value, body)
}
pub fn overwrite<T: Serialize + HasName>(
&self,
filename: &str,
value: &T,
body: Option<&str>,
) -> Result<PathBuf> {
if filename.trim().is_empty() {
return Err(TixError::InvalidInput(
"record filename must not be blank".into(),
));
}
std::fs::create_dir_all(&self.dir)?;
let path = self.dir.join(filename);
let frontmatter = serde_yaml::to_string(value)
.map_err(|e| TixError::Parse(format!("cannot serialize record frontmatter: {e}")))?;
let contents = format!("---\n{frontmatter}---\n\n{}", render_body(body));
std::fs::write(&path, contents)?;
Ok(path)
}
pub fn rename_file(&self, old_filename: &str, new_filename: &str) -> Result<PathBuf> {
let old = self.dir.join(old_filename);
let new = self.dir.join(new_filename);
if !old.is_file() {
return Err(TixError::NotFound(format!("`{}`", old.display())));
}
if new.exists() {
return Err(TixError::InvalidInput(format!(
"`{}` already exists",
new.display()
)));
}
std::fs::rename(&old, &new)?;
Ok(new)
}
pub fn delete_file(&self, filename: &str) -> Result<()> {
let path = self.dir.join(filename);
if !path.is_file() {
return Err(TixError::NotFound(format!("`{}`", path.display())));
}
std::fs::remove_file(path)?;
Ok(())
}
pub fn body_of(&self, filename: &str) -> Option<String> {
let contents = std::fs::read_to_string(self.dir.join(filename)).ok()?;
let (_frontmatter, body) = split_frontmatter(&contents)?;
Some(body.to_string())
}
}
pub fn split_frontmatter(contents: &str) -> Option<(&str, &str)> {
let rest = contents.strip_prefix("---\n")?;
let mut current = rest;
let mut fm_len = 0usize;
loop {
let line_end = current.find('\n').map(|i| i + 1).unwrap_or(current.len());
let content = current[..line_end]
.strip_suffix('\n')
.unwrap_or(¤t[..line_end]);
if content == "---" {
break;
}
fm_len += line_end;
current = ¤t[line_end..];
if current.is_empty() {
return None;
}
}
let mut body = &rest[fm_len..];
body = body.strip_prefix("---\n").unwrap_or(body);
if let Some(after) = body.strip_prefix('\n') {
body = after;
}
Some((&rest[..fm_len], body))
}
fn render_body(body: Option<&str>) -> String {
match body {
Some(text) if !text.trim().is_empty() => format!("{}\n", text.trim_end()),
_ => String::new(),
}
}
fn parse_record<T: DeserializeOwned>(contents: &str) -> Option<(T, &str)> {
let (frontmatter, body) = split_frontmatter(contents)?;
let value: T = serde_yaml::from_str(frontmatter).ok()?;
Some((value, body))
}
#[cfg(test)]
mod tests {
use serde::{Deserialize, Serialize};
use tempfile::TempDir;
use super::*;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default, deny_unknown_fields)]
struct Sample {
name: String,
count: u32,
}
impl HasName for Sample {
fn name(&self) -> &str {
&self.name
}
}
#[test]
fn record_round_trips_and_reading_does_not_create_the_directory() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("docs").join("samples");
let store = RecordStore::new(&dir);
assert!(!store.exists());
assert_eq!(
store.list::<Sample>().unwrap(),
[] as [crate::records::RecordFile<crate::records::tests::Sample>; 0]
);
assert!(!dir.exists(), "reading must not create the store directory");
let record = Sample {
name: "Alpha".into(),
count: 3,
};
let path = store.write("alpha.md", &record, Some("a body")).unwrap();
assert!(dir.is_dir(), "a write creates the directory");
assert_eq!(path.file_name().unwrap(), "alpha.md");
let raw = std::fs::read_to_string(&path).unwrap();
assert!(raw.starts_with("---\n"), "{raw}");
assert!(raw.contains("name: Alpha"), "{raw}");
assert!(
raw.ends_with("a body\n"),
"body has one trailing newline: {raw}"
);
let listed = store.list::<Sample>().unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].name, "Alpha");
assert_eq!(listed[0].filename, "alpha.md");
assert_eq!(listed[0].value, record);
assert_eq!(listed[0].body, "a body\n");
assert_eq!(store.get::<Sample>("Alpha").unwrap().value, record);
let err = store.write("alpha.md", &record, None).unwrap_err();
assert!(matches!(err, TixError::InvalidInput(_)), "{err:?}");
let mut changed = record.clone();
changed.count = 9;
store.overwrite("alpha.md", &changed, None).unwrap();
assert_eq!(store.get::<Sample>("Alpha").unwrap().value.count, 9);
store.rename_file("alpha.md", "renamed.md").unwrap();
assert_eq!(store.get::<Sample>("Alpha").unwrap().filename, "renamed.md");
store.delete_file("renamed.md").unwrap();
assert_eq!(
store.list::<Sample>().unwrap(),
[] as [crate::records::RecordFile<crate::records::tests::Sample>; 0]
);
}
#[test]
fn list_checked_reports_a_record_without_a_name() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("samples");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("good.md"),
"---\nname: Good\ncount: 1\n---\n\nbody\n",
)
.unwrap();
std::fs::write(dir.join("nameless.md"), "---\ncount: 2\n---\n\nbody\n").unwrap();
std::fs::write(dir.join("broken.md"), "not a record\n").unwrap();
std::fs::write(dir.join("blank-name.md"), "---\nname: \"\"\n---\n\nbody\n").unwrap();
let store = RecordStore::new(&dir);
let (records, skipped) = store.list_checked::<Sample>().unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].name, "Good");
assert_eq!(
skipped,
vec![
"blank-name.md".to_string(),
"broken.md".into(),
"nameless.md".into()
],
"every unreadable file is reported, not silently dropped"
);
assert_eq!(store.list::<Sample>().unwrap().len(), 1);
assert!(matches!(
store.get::<Sample>("nope").unwrap_err(),
TixError::NotFound(_)
));
}
#[test]
fn get_finds_a_record_whose_name_differs_from_its_filename() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("samples");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("weird.md"), "---\nname: Real Name\n---\n\n").unwrap();
let store = RecordStore::new(&dir);
assert_eq!(
store.get::<Sample>("Real Name").unwrap().filename,
"weird.md"
);
}
}