use std::path::{Path, PathBuf};
use anyhow::Context;
use crate::infra::serde_support::RawEvent;
use super::enrich::EnrichWarning;
use super::events_jsonl::read_events_jsonl;
use super::frontmatter::{slugify, split_frontmatter};
use super::fs_utils::{collect_index_paths, directory_prefix, find_subdir};
pub(crate) struct ScanEntry<R> {
pub path: PathBuf,
pub result: Result<R, String>,
pub warnings: Vec<EnrichWarning>,
}
pub(crate) trait FileSystemRecord: Sized {
type Raw: serde::de::DeserializeOwned;
type ParseCtx;
type EnrichCtx;
fn from_raw(
raw: Self::Raw,
body: String,
path: &Path,
schema_version: u32,
ctx: &Self::ParseCtx,
) -> anyhow::Result<(Self, Vec<RawEvent>)>;
fn enrich(&mut self, raw_events: Vec<RawEvent>, ctx: &Self::EnrichCtx) -> Vec<EnrichWarning>;
}
pub(crate) fn parse_one<R: FileSystemRecord>(
path: &Path,
schema_version: u32,
ctx: &R::ParseCtx,
) -> anyhow::Result<(R, Vec<RawEvent>)> {
let raw =
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
let (fm, body) = split_frontmatter(&raw).context("missing frontmatter")?;
let raw_fm: R::Raw = serde_yaml::from_str(fm).context("invalid frontmatter")?;
let (record, mut raw_events) =
R::from_raw(raw_fm, body.to_string(), path, schema_version, ctx)?;
raw_events.extend(read_events_jsonl(path)?);
Ok((record, raw_events))
}
pub(crate) fn list<R: FileSystemRecord>(
dir: &Path,
schema_version: u32,
enrich_ctx: &R::EnrichCtx,
ctx: &R::ParseCtx,
) -> anyhow::Result<Vec<R>> {
let paths = collect_index_paths(dir)?;
let mut out = Vec::new();
for path in paths {
match parse_one::<R>(&path, schema_version, ctx) {
Ok((mut record, raw_events)) => {
record.enrich(raw_events, enrich_ctx);
out.push(record);
}
Err(e) => {
eprintln!("warning: skipped {}: {:#}", path.display(), e);
}
}
}
Ok(out)
}
pub(crate) fn find_by_id<R: FileSystemRecord>(
dir: &Path,
id_suffix: &str,
schema_version: u32,
enrich_ctx: &R::EnrichCtx,
ctx: &R::ParseCtx,
) -> anyhow::Result<Option<R>> {
let Some(subdir) = find_subdir(dir, id_suffix) else {
return Ok(None);
};
let index = subdir.join("index.md");
if !index.exists() {
return Ok(None);
}
let (mut record, raw_events) = parse_one::<R>(&index, schema_version, ctx)?;
record.enrich(raw_events, enrich_ctx);
Ok(Some(record))
}
pub(crate) fn scan_all<R: FileSystemRecord>(
dir: &Path,
schema_version: u32,
enrich_ctx: &R::EnrichCtx,
ctx: &R::ParseCtx,
) -> anyhow::Result<Vec<ScanEntry<R>>> {
let mut paths = collect_index_paths(dir)?;
paths.sort();
let mut entries: Vec<ScanEntry<R>> = Vec::new();
for path in paths {
let (result, warnings) = match parse_one::<R>(&path, schema_version, ctx) {
Ok((mut record, raw_events)) => {
let warnings = record.enrich(raw_events, enrich_ctx);
(Ok(record), warnings)
}
Err(e) => (Err(format!("{e:#}")), Vec::new()),
};
entries.push(ScanEntry {
path,
result,
warnings,
});
}
Ok(entries)
}
pub(crate) fn save_index(
dir: &Path,
id_suffix: &str,
slug_seed: &str,
content: &str,
) -> anyhow::Result<PathBuf> {
let target_name = format!("{}-{}", directory_prefix(id_suffix), slugify(slug_seed));
let target_dir = dir.join(&target_name);
let record_dir = match find_subdir(dir, id_suffix) {
Some(existing) if existing != target_dir => {
std::fs::rename(&existing, &target_dir).with_context(|| {
format!(
"renaming {} to {}",
existing.display(),
target_dir.display()
)
})?;
target_dir
}
Some(existing) => existing,
None => target_dir,
};
std::fs::create_dir_all(&record_dir)
.with_context(|| format!("creating directory {}", record_dir.display()))?;
let file_path = record_dir.join("index.md");
let tmp_path = record_dir.join("index.md.tmp");
std::fs::write(&tmp_path, content)
.with_context(|| format!("writing record to {}", tmp_path.display()))?;
std::fs::rename(&tmp_path, &file_path)
.with_context(|| format!("renaming {} to {}", tmp_path.display(), file_path.display()))?;
Ok(record_dir)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn save_index_renames_dir_when_slug_changes() {
let td = TempDir::new().unwrap();
let dir = td.path();
save_index(dir, "0042", "old slug", "v1").unwrap();
assert!(dir.join("0042-old-slug").is_dir());
save_index(dir, "0042", "new slug", "v2").unwrap();
assert!(!dir.join("0042-old-slug").exists());
assert!(dir.join("0042-new-slug").is_dir());
assert_eq!(
std::fs::read_to_string(dir.join("0042-new-slug/index.md")).unwrap(),
"v2"
);
}
#[test]
fn save_index_preserves_companion_files_when_renaming() {
let td = TempDir::new().unwrap();
let dir = td.path();
save_index(dir, "0042", "old slug", "v1").unwrap();
std::fs::write(dir.join("0042-old-slug/plan.md"), "plan body").unwrap();
save_index(dir, "0042", "new slug", "v2").unwrap();
assert_eq!(
std::fs::read_to_string(dir.join("0042-new-slug/plan.md")).unwrap(),
"plan body"
);
}
#[test]
fn save_index_leaves_dir_untouched_when_slug_unchanged() {
let td = TempDir::new().unwrap();
let dir = td.path();
save_index(dir, "0042", "same slug", "v1").unwrap();
save_index(dir, "0042", "same slug", "v2").unwrap();
assert!(dir.join("0042-same-slug").is_dir());
assert_eq!(
std::fs::read_to_string(dir.join("0042-same-slug/index.md")).unwrap(),
"v2"
);
}
#[test]
fn save_index_renames_dir_for_tsid_suffix() {
let td = TempDir::new().unwrap();
let dir = td.path();
let tsid = "01BCSS7MP15H3";
save_index(dir, tsid, "first title", "v1").unwrap();
let first = dir.join(format!("{tsid}-first-title"));
assert!(first.is_dir());
save_index(dir, tsid, "second title", "v2").unwrap();
assert!(!first.exists());
assert!(dir.join(format!("{tsid}-second-title")).is_dir());
}
}