cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! [`MigrationCorpus`] is the port through which migration steps reach the
//! filesystem. Steps never touch `std::fs`; they read, write, and rename via
//! a corpus handed down through the `MigrationCtx`. This lets the domain
//! stay testable without a tempfile and lets the CLI compose a dry-run by
//! wrapping the real adapter.
//!
//! Three implementations ship in the tree:
//!
//! - `FsMigrationCorpus` (in `infra::driven::fs`) — production adapter.
//! - [`DryRunCorpus`] — wraps any inner corpus, discards `write` and
//!   `rename` so a `--dry-run` run leaves the disk untouched.
//! - [`FakeMigrationCorpus`] — in-memory adapter for step unit tests.

use std::cell::RefCell;
use std::collections::HashMap;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};

pub trait MigrationCorpus {
    /// Walk every `dirs[i]` and return the full set of `**/index.md` paths
    /// found below them. Order is unspecified.
    fn collect_index_paths(&self, dirs: &[PathBuf]) -> Vec<PathBuf>;

    /// Read the entire file at `path` as UTF-8. Returns an error if the
    /// file does not exist or is not valid UTF-8.
    fn read(&self, path: &Path) -> anyhow::Result<String>;

    /// Write `content` to `path`, replacing any prior content.
    fn write(&self, path: &Path, content: &str) -> anyhow::Result<()>;

    /// Move the *directory* at `from` to `to` (recursive move on the fake;
    /// `std::fs::rename` on the production adapter).
    fn rename(&self, from: &Path, to: &Path) -> anyhow::Result<()>;

    /// Whether a file is present at `path`. Distinct from `read` failing,
    /// which conflates "absent" with "unreadable".
    fn exists(&self, path: &Path) -> bool;
}

/// Wraps an inner corpus and silently swallows mutations. `read` and
/// `collect_index_paths` still hit the inner adapter so the steps observe
/// the real corpus state — only the destructive side stops.
///
/// One subtlety: the v3→v4 id rewrite *renames* directories. With this
/// wrapper, subsequent steps would try to read non-existent post-rename
/// paths. The step still has to advance `ctx.paths` only on a real run —
/// it consults `ctx.dry_run` for that single decision. Every other step
/// is dry-run-agnostic.
pub struct DryRunCorpus<C: MigrationCorpus> {
    inner: C,
}

impl<C: MigrationCorpus> DryRunCorpus<C> {
    pub fn new(inner: C) -> Self {
        Self { inner }
    }
}

impl<C: MigrationCorpus> MigrationCorpus for DryRunCorpus<C> {
    fn collect_index_paths(&self, dirs: &[PathBuf]) -> Vec<PathBuf> {
        self.inner.collect_index_paths(dirs)
    }

    fn read(&self, path: &Path) -> anyhow::Result<String> {
        self.inner.read(path)
    }

    fn write(&self, _path: &Path, _content: &str) -> anyhow::Result<()> {
        Ok(())
    }

    fn rename(&self, _from: &Path, _to: &Path) -> anyhow::Result<()> {
        Ok(())
    }

    fn exists(&self, path: &Path) -> bool {
        self.inner.exists(path)
    }
}

/// In-memory corpus used by step unit tests. Keys are absolute-shaped
/// `PathBuf`s; values are the file content as UTF-8 strings.
///
/// "Directory rename" is emulated by moving every key whose prefix matches
/// `from` to the equivalent prefix under `to`.
pub struct FakeMigrationCorpus {
    files: RefCell<HashMap<PathBuf, String>>,
}

impl FakeMigrationCorpus {
    pub fn new() -> Self {
        Self {
            files: RefCell::new(HashMap::new()),
        }
    }

    /// Seed the corpus with a list of `(path, content)` entries.
    pub fn with(entries: &[(&str, &str)]) -> Self {
        let mut files = HashMap::new();
        for (p, c) in entries {
            files.insert(PathBuf::from(p), (*c).to_string());
        }
        Self {
            files: RefCell::new(files),
        }
    }

    /// Test helper: panic if absent. Use this in assertions when you know
    /// a step should have produced a file.
    pub fn snapshot(&self, path: &str) -> String {
        self.files
            .borrow()
            .get(&PathBuf::from(path))
            .cloned()
            .unwrap_or_else(|| panic!("path missing from corpus: {path}"))
    }

    /// Test helper: returns true if a key with the given path is present.
    pub fn has(&self, path: &str) -> bool {
        self.files.borrow().contains_key(&PathBuf::from(path))
    }

    /// Test helper: returns every path currently in the corpus.
    pub fn paths(&self) -> Vec<PathBuf> {
        self.files.borrow().keys().cloned().collect()
    }
}

impl Default for FakeMigrationCorpus {
    fn default() -> Self {
        Self::new()
    }
}

impl MigrationCorpus for FakeMigrationCorpus {
    fn collect_index_paths(&self, dirs: &[PathBuf]) -> Vec<PathBuf> {
        self.files
            .borrow()
            .keys()
            .filter(|p| {
                p.file_name() == Some(OsStr::new("index.md"))
                    && dirs.iter().any(|d| p.starts_with(d))
            })
            .cloned()
            .collect()
    }

    fn read(&self, path: &Path) -> anyhow::Result<String> {
        self.files
            .borrow()
            .get(path)
            .cloned()
            .ok_or_else(|| anyhow::anyhow!("not found: {}", path.display()))
    }

    fn write(&self, path: &Path, content: &str) -> anyhow::Result<()> {
        self.files
            .borrow_mut()
            .insert(path.to_path_buf(), content.to_string());
        Ok(())
    }

    fn exists(&self, path: &Path) -> bool {
        self.files.borrow().contains_key(path)
    }

    fn rename(&self, from: &Path, to: &Path) -> anyhow::Result<()> {
        let mut files = self.files.borrow_mut();
        let moves: Vec<(PathBuf, PathBuf)> = files
            .keys()
            .filter(|k| k.starts_with(from))
            .map(|k| {
                let rel = k.strip_prefix(from).expect("filtered above");
                (k.clone(), to.join(rel))
            })
            .collect();
        for (old, new) in moves {
            let content = files.remove(&old).expect("moved key existed");
            files.insert(new, content);
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn fake_round_trips_read_write() {
        let corpus = FakeMigrationCorpus::with(&[("docs/a/index.md", "hello")]);
        corpus.write(Path::new("docs/a/index.md"), "world").unwrap();
        assert_eq!(corpus.read(Path::new("docs/a/index.md")).unwrap(), "world");
    }

    #[test]
    fn fake_rename_moves_every_key_under_from() {
        let corpus = FakeMigrationCorpus::with(&[
            ("docs/issues/0001-foo/index.md", "a"),
            ("docs/issues/0001-foo/plan.md", "b"),
            ("docs/issues/0002-bar/index.md", "c"),
        ]);
        corpus
            .rename(
                Path::new("docs/issues/0001-foo"),
                Path::new("docs/issues/0DCT-foo"),
            )
            .unwrap();
        assert!(corpus.has("docs/issues/0DCT-foo/index.md"));
        assert!(corpus.has("docs/issues/0DCT-foo/plan.md"));
        assert!(!corpus.has("docs/issues/0001-foo/index.md"));
        assert!(corpus.has("docs/issues/0002-bar/index.md"));
    }

    #[test]
    fn collect_index_paths_filters_by_dir_and_filename() {
        let corpus = FakeMigrationCorpus::with(&[
            ("docs/issues/0001-foo/index.md", ""),
            ("docs/issues/0001-foo/plan.md", ""),
            ("docs/adr/0001-bar/index.md", ""),
            ("other/skip/index.md", ""),
        ]);
        let dirs = vec![PathBuf::from("docs/issues"), PathBuf::from("docs/adr")];
        let mut paths = corpus.collect_index_paths(&dirs);
        paths.sort();
        assert_eq!(
            paths,
            vec![
                PathBuf::from("docs/adr/0001-bar/index.md"),
                PathBuf::from("docs/issues/0001-foo/index.md"),
            ]
        );
    }

    #[test]
    fn dry_run_corpus_discards_write_and_rename_but_forwards_reads() {
        let inner = FakeMigrationCorpus::with(&[("a", "original")]);
        let dry = DryRunCorpus::new(inner);
        dry.write(Path::new("a"), "mutated").unwrap();
        dry.rename(Path::new("a"), Path::new("b")).unwrap();
        assert_eq!(dry.read(Path::new("a")).unwrap(), "original");
    }
}