use std::cell::RefCell;
use std::collections::HashMap;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
pub trait MigrationCorpus {
fn collect_index_paths(&self, dirs: &[PathBuf]) -> Vec<PathBuf>;
fn read(&self, path: &Path) -> anyhow::Result<String>;
fn write(&self, path: &Path, content: &str) -> anyhow::Result<()>;
fn rename(&self, from: &Path, to: &Path) -> anyhow::Result<()>;
fn exists(&self, path: &Path) -> bool;
}
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)
}
}
pub struct FakeMigrationCorpus {
files: RefCell<HashMap<PathBuf, String>>,
}
impl FakeMigrationCorpus {
pub fn new() -> Self {
Self {
files: RefCell::new(HashMap::new()),
}
}
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),
}
}
pub fn snapshot(&self, path: &str) -> String {
self.files
.borrow()
.get(&PathBuf::from(path))
.cloned()
.unwrap_or_else(|| panic!("path missing from corpus: {path}"))
}
pub fn has(&self, path: &str) -> bool {
self.files.borrow().contains_key(&PathBuf::from(path))
}
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");
}
}