cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Generic plumbing shared by [`FsDecisionRecordRepository`] and
//! [`FsIssueRepository`]. The [`FileSystemRecord`] trait captures the small
//! number of type-specific hooks (raw frontmatter shape, entity assembly,
//! enrichment hint); the rest of the pipeline — reading the file,
//! splitting frontmatter, enriching events, writing the canonical layout,
//! allocating new ids — lives here.

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};

/// One scan outcome produced by [`scan_all`]. Internal to the pipeline —
/// the FS defect scanners consume it to assemble domain `MalformedEntry`
/// values; the FS repositories never expose it.
pub(crate) struct ScanEntry<R> {
    pub path: PathBuf,
    pub result: Result<R, String>,
    pub warnings: Vec<EnrichWarning>,
}

/// Type-specific hooks for the shared filesystem pipeline.
///
/// `Raw` is the deserialized YAML frontmatter struct; `ParseCtx` carries
/// any extra runtime context the parser needs (e.g. the `kind` for
/// decision records).
pub(crate) trait FileSystemRecord: Sized {
    type Raw: serde::de::DeserializeOwned;
    type ParseCtx;
    /// Per-universe enrichment context: issue uses `StatusesConfig`, DR
    /// uses `()` because its workflow is hardcoded (DDR-018QWJVHRH35B).
    type EnrichCtx;

    /// Build the entity from a parsed frontmatter + body. Returns the entity
    /// (with empty events) and the raw events still to be enriched.
    fn from_raw(
        raw: Self::Raw,
        body: String,
        path: &Path,
        schema_version: u32,
        ctx: &Self::ParseCtx,
    ) -> anyhow::Result<(Self, Vec<RawEvent>)>;

    /// Apply event-log enrichment in place: project the current status
    /// from the log, emit a typed warning when the frontmatter and the
    /// projection disagree.
    fn enrich(&mut self, raw_events: Vec<RawEvent>, ctx: &Self::EnrichCtx) -> Vec<EnrichWarning>;
}

/// Read and parse one `index.md` file into a record + raw events.
///
/// Events come from two sources today: the legacy `events:` YAML block
/// inside the frontmatter (pre-v7 records) and the `events.jsonl` companion
/// sibling (post-v7 records). After migration the frontmatter source is
/// empty and the JSONL source is authoritative; before migration the JSONL
/// is absent and the frontmatter source carries everything. Concatenating
/// both keeps the pipeline functional through the transition.
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))
}

/// Generic `list`: scan the directory, parse every `index.md`, enrich events.
/// Files that fail to parse are kept out of the result but a warning is
/// emitted on stderr so the user notices something was skipped — matching
/// the visibility of `cartu check`. Callers that need a structured error
/// channel should use [`scan_all`] instead.
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)
}

/// Generic `find_by_id`: locate the subdirectory by id suffix, parse, enrich.
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))
}

/// Generic `scan_all`: parse every `index.md`, surface per-file errors.
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)
}

/// Write a canonical `index.md` under the record's directory. When a
/// directory already matches `id_suffix`, it is reused — and renamed in
/// place if its slug no longer matches the current `slug_seed`, so a title
/// edit propagates to the on-disk layout. `slug_seed` is the human-readable
/// text — typically the title — that is slugified into the directory name.
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");
    // Per-file atomicity: write to a sibling .tmp, then POSIX rename(2)
    // (atomic on the same filesystem). A crash between the two steps
    // leaves the target either fully old or fully new — never half-
    // written. See plan.md § "Two-file write atomicity".
    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());
    }
}