use crate::core::backend::GeneratedFile;
use anyhow::{Context, Result, bail};
use serde::Serialize;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MigrationStatus {
Identical,
Different,
NoGeneratedEquivalent,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct MigrationEntry {
pub path: PathBuf,
pub status: MigrationStatus,
#[serde(default)]
pub curated: bool,
}
pub fn compare_root(
existing_root: &Path,
generated_root: &Path,
generated: &[GeneratedFile],
) -> Result<Vec<MigrationEntry>> {
compare_root_curated(&CuratedComparison {
project_root: existing_root,
existing_root,
generated_root,
generated,
curated_globs: &[],
})
}
pub struct CuratedComparison<'a> {
pub project_root: &'a Path,
pub existing_root: &'a Path,
pub generated_root: &'a Path,
pub generated: &'a [GeneratedFile],
pub curated_globs: &'a [String],
}
pub fn compare_root_curated(comparison: &CuratedComparison<'_>) -> Result<Vec<MigrationEntry>> {
let &CuratedComparison {
project_root,
existing_root,
generated_root,
generated,
curated_globs,
} = comparison;
let existing = read_existing(existing_root)?;
let generated_prefix = nested_prefix(existing_root, generated_root);
let curated_base = curated_base(project_root, existing_root, curated_globs)?;
let relative_generated = generated
.iter()
.map(|file| {
let path = file.path.strip_prefix(generated_root).with_context(|| {
format!(
"generated snippet {} is outside configured output {}",
file.path.display(),
generated_root.display()
)
})?;
let path = match &generated_prefix {
Some(prefix) => prefix.join(path),
None => path.to_path_buf(),
};
Ok(GeneratedFile {
path,
content: file.content.clone(),
generated_header: file.generated_header,
})
})
.collect::<Result<Vec<_>>>()?;
compare_existing_curated(
existing
.iter()
.map(|(path, content)| (path.as_path(), content.as_str())),
&relative_generated,
curated_globs,
&curated_base,
)
}
fn curated_base(project_root: &Path, existing_root: &Path, curated_globs: &[String]) -> Result<PathBuf> {
let project_root = if project_root.as_os_str().is_empty() {
Path::new(".")
} else {
project_root
};
if let Some(prefix) = nested_prefix(project_root, existing_root) {
return Ok(prefix);
}
let same_root = match (std::path::absolute(project_root), std::path::absolute(existing_root)) {
(Ok(project), Ok(existing)) => project == existing,
_ => false,
};
if same_root || curated_globs.is_empty() {
return Ok(PathBuf::new());
}
bail!(
"curated snippet globs are relative to the project root `{}`, but the migrated root `{}` does not \
lie beneath it; pass a migrated root inside the project",
project_root.display(),
existing_root.display()
)
}
fn nested_prefix(outer: &Path, inner: &Path) -> Option<PathBuf> {
let non_empty = |prefix: PathBuf| (!prefix.as_os_str().is_empty()).then_some(prefix);
if let Ok(prefix) = inner.strip_prefix(outer)
&& !prefix.is_absolute()
{
return non_empty(prefix.to_path_buf());
}
let outer = std::path::absolute(outer).ok()?;
let inner = std::path::absolute(inner).ok()?;
non_empty(inner.strip_prefix(&outer).ok()?.to_path_buf())
}
fn read_existing(root: &Path) -> Result<Vec<(PathBuf, String)>> {
if !root.is_dir() {
bail!("existing snippet root is not a directory: {}", root.display());
}
let mut files = Vec::new();
read_directory(root, root, &mut files)?;
files.sort_by(|left, right| left.0.cmp(&right.0));
Ok(files)
}
fn read_directory(root: &Path, directory: &Path, files: &mut Vec<(PathBuf, String)>) -> Result<()> {
let entries =
fs::read_dir(directory).with_context(|| format!("failed to read snippet directory {}", directory.display()))?;
for entry in entries {
let entry = entry.with_context(|| format!("failed to read entry in {}", directory.display()))?;
let file_type = entry
.file_type()
.with_context(|| format!("failed to inspect {}", entry.path().display()))?;
if file_type.is_dir() {
read_directory(root, &entry.path(), files)?;
} else if file_type.is_file() {
let path = entry.path();
if crate::e2e::snippets::is_snippet_coverage_manifest_path(&path) {
continue;
}
let relative = path
.strip_prefix(root)
.with_context(|| format!("failed to relativize {}", path.display()))?
.to_path_buf();
let content = fs::read_to_string(&path)
.with_context(|| format!("failed to read snippet {} as UTF-8", path.display()))?;
files.push((relative, content));
}
}
Ok(())
}
pub fn compare_existing<'a>(
existing: impl IntoIterator<Item = (&'a Path, &'a str)>,
generated: &[GeneratedFile],
) -> Vec<MigrationEntry> {
compare_existing_curated(existing, generated, &[], Path::new(""))
.expect("no curated globs to compile means this can never fail")
}
pub fn compare_existing_curated<'a>(
existing: impl IntoIterator<Item = (&'a Path, &'a str)>,
generated: &[GeneratedFile],
curated_globs: &[String],
curated_base: &Path,
) -> Result<Vec<MigrationEntry>> {
let compiled_globs = curated_globs
.iter()
.map(|pattern| glob::Pattern::new(pattern).with_context(|| format!("invalid curated snippet glob `{pattern}`")))
.collect::<Result<Vec<_>>>()?;
let expected: BTreeMap<_, _> = generated
.iter()
.map(|file| (file.path.as_path(), file.content.as_str()))
.collect();
Ok(existing
.into_iter()
.map(|(path, content)| {
let status = match expected.get(path) {
Some(expected) if *expected == content => MigrationStatus::Identical,
Some(_) => MigrationStatus::Different,
None => MigrationStatus::NoGeneratedEquivalent,
};
let curated_key = curated_base.join(path);
let curated = status == MigrationStatus::NoGeneratedEquivalent
&& compiled_globs.iter().any(|pattern| pattern.matches_path(&curated_key));
MigrationEntry {
path: path.to_path_buf(),
status,
curated,
}
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compare_root_recurses_and_reports_stable_relative_paths() {
let directory = tempfile::tempdir().expect("tempdir");
fs::create_dir_all(directory.path().join("python/topic")).expect("create nested directory");
fs::write(directory.path().join("python/topic/a.md"), "same").expect("write identical snippet");
fs::write(directory.path().join("python/topic/b.md"), "old").expect("write different snippet");
fs::write(directory.path().join("orphan.md"), "manual").expect("write orphan snippet");
let generated = vec![
generated("docs/generated/python/topic/a.md", "same"),
generated("docs/generated/python/topic/b.md", "new"),
];
let entries =
compare_root(directory.path(), Path::new("docs/generated"), &generated).expect("compare snippets");
assert_eq!(
entries,
vec![
MigrationEntry {
path: PathBuf::from("orphan.md"),
status: MigrationStatus::NoGeneratedEquivalent,
curated: false,
},
MigrationEntry {
path: PathBuf::from("python/topic/a.md"),
status: MigrationStatus::Identical,
curated: false,
},
MigrationEntry {
path: PathBuf::from("python/topic/b.md"),
status: MigrationStatus::Different,
curated: false,
},
]
);
}
#[test]
fn compare_root_curated_flags_paths_a_curated_glob_claims() {
let directory = tempfile::tempdir().expect("tempdir");
fs::create_dir_all(directory.path().join("docker")).expect("create curated directory");
fs::write(directory.path().join("docker/quick-start.md"), "curated by hand").expect("write curated snippet");
fs::write(directory.path().join("orphan.md"), "manual").expect("write uncurated orphan snippet");
let generated: Vec<GeneratedFile> = Vec::new();
let entries = compare_root_curated(&CuratedComparison {
project_root: directory.path(),
existing_root: directory.path(),
generated_root: Path::new("docs/generated"),
generated: &generated,
curated_globs: &["docker/*.md".to_string()],
})
.expect("curated comparison succeeds");
assert_eq!(
entries,
vec![
MigrationEntry {
path: PathBuf::from("docker/quick-start.md"),
status: MigrationStatus::NoGeneratedEquivalent,
curated: true,
},
MigrationEntry {
path: PathBuf::from("orphan.md"),
status: MigrationStatus::NoGeneratedEquivalent,
curated: false,
},
]
);
}
#[test]
fn a_curated_glob_matching_a_path_with_a_real_generated_equivalent_leaves_its_status_untouched() {
let directory = tempfile::tempdir().expect("tempdir");
fs::create_dir_all(directory.path().join("python")).expect("create directory");
fs::write(directory.path().join("python/example.md"), "old").expect("write stale snippet");
let generated = vec![generated("docs/generated/python/example.md", "new")];
let entries = compare_root_curated(&CuratedComparison {
project_root: directory.path(),
existing_root: directory.path(),
generated_root: Path::new("docs/generated"),
generated: &generated,
curated_globs: &["python/*.md".to_string()],
})
.expect("curated comparison succeeds");
assert_eq!(
entries,
vec![MigrationEntry {
path: PathBuf::from("python/example.md"),
status: MigrationStatus::Different,
curated: false,
}]
);
}
#[test]
fn an_invalid_curated_glob_fails_the_comparison_rather_than_silently_matching_nothing() {
let directory = tempfile::tempdir().expect("tempdir");
fs::write(directory.path().join("orphan.md"), "manual").expect("write orphan snippet");
let generated: Vec<GeneratedFile> = Vec::new();
let error = compare_root_curated(&CuratedComparison {
project_root: directory.path(),
existing_root: directory.path(),
generated_root: Path::new("docs/generated"),
generated: &generated,
curated_globs: &["[unterminated".to_string()],
})
.expect_err("an invalid glob pattern must fail rather than silently match nothing");
assert!(error.to_string().contains("invalid curated snippet glob"), "{error}");
}
#[test]
fn a_generated_tree_nested_inside_the_migrated_root_is_matched_not_reported_as_a_gap() {
let directory = tempfile::tempdir().expect("tempdir");
let existing_root = directory.path();
let generated_root = existing_root.join("generated");
fs::create_dir_all(generated_root.join("python")).expect("create generated tree");
fs::create_dir_all(existing_root.join("cli")).expect("create hand-authored tree");
fs::write(generated_root.join("python/a.md"), "alef:hash:abc\nsame").expect("write fresh generated snippet");
fs::write(generated_root.join("python/b.md"), "alef:hash:def\nstale").expect("write stale generated snippet");
fs::write(existing_root.join("cli/quickstart.md"), "by hand").expect("write hand-authored snippet");
let generated = vec![
generated(
&generated_root.join("python/a.md").to_string_lossy(),
"alef:hash:abc\nsame",
),
generated(
&generated_root.join("python/b.md").to_string_lossy(),
"alef:hash:def\nfresh",
),
];
let entries = compare_root(existing_root, &generated_root, &generated).expect("compare snippets");
assert_eq!(
entries,
vec![
MigrationEntry {
path: PathBuf::from("cli/quickstart.md"),
status: MigrationStatus::NoGeneratedEquivalent,
curated: false,
},
MigrationEntry {
path: PathBuf::from("generated/python/a.md"),
status: MigrationStatus::Identical,
curated: false,
},
MigrationEntry {
path: PathBuf::from("generated/python/b.md"),
status: MigrationStatus::Different,
curated: false,
},
],
"a file alef itself generates must never be reported as having no generated equivalent"
);
}
#[test]
fn the_coverage_ledger_is_not_reported_as_a_migration_gap() {
let directory = tempfile::tempdir().expect("tempdir");
let existing_root = directory.path();
fs::create_dir_all(existing_root.join("generated")).expect("create generated tree");
fs::write(existing_root.join("generated/.alef-snippet-coverage.json"), "{}").expect("write ledger");
fs::write(existing_root.join("orphan.md"), "by hand").expect("write orphan snippet");
let entries = compare_root(existing_root, &existing_root.join("generated"), &[]).expect("compare snippets");
assert_eq!(
entries,
vec![MigrationEntry {
path: PathBuf::from("orphan.md"),
status: MigrationStatus::NoGeneratedEquivalent,
curated: false,
}],
"alef's own coverage ledger must never be reported as a file the project should author"
);
}
#[test]
fn equal_existing_and_generated_roots_compare_correctly_without_double_prefixing() {
let directory = tempfile::tempdir().expect("tempdir");
let root = directory.path().join("docs-site/src/snippets");
fs::create_dir_all(root.join("rust/topic")).expect("create rust tree");
fs::create_dir_all(root.join("python/topic")).expect("create python tree");
fs::write(root.join("rust/topic/a.md"), "same content").expect("write fresh rust snippet");
fs::write(root.join("python/topic/a.md"), "stale content").expect("write stale python snippet");
let generated = vec![
generated(&root.join("rust/topic/a.md").to_string_lossy(), "same content"),
generated(&root.join("python/topic/a.md").to_string_lossy(), "fresh content"),
];
let entries = compare_root(&root, &root, &generated).expect("compare snippets against an equal root");
assert_eq!(
entries,
vec![
MigrationEntry {
path: PathBuf::from("python/topic/a.md"),
status: MigrationStatus::Different,
curated: false,
},
MigrationEntry {
path: PathBuf::from("rust/topic/a.md"),
status: MigrationStatus::Identical,
curated: false,
},
],
"a migrated root equal to the configured output must match every file alef itself \
generated, never report it as having no generated equivalent"
);
}
#[test]
fn equal_roots_compare_correctly_through_the_curated_aware_entry_point_too() {
let directory = tempfile::tempdir().expect("tempdir");
let project_root = directory.path();
let existing_root = project_root.join("docs-site/src/snippets");
fs::create_dir_all(existing_root.join("rust/topic")).expect("create rust tree");
fs::create_dir_all(existing_root.join("cli")).expect("create curated directory");
fs::write(existing_root.join("rust/topic/a.md"), "same content").expect("write fresh rust snippet");
fs::write(existing_root.join("cli/quickstart.md"), "by hand").expect("write curated snippet");
let generated = vec![generated(
&existing_root.join("rust/topic/a.md").to_string_lossy(),
"same content",
)];
let entries = compare_root_curated(&CuratedComparison {
project_root,
existing_root: &existing_root,
generated_root: &existing_root,
generated: &generated,
curated_globs: &["docs-site/src/snippets/cli/*.md".to_string()],
})
.expect("curated comparison over an equal root succeeds");
assert_eq!(
entries,
vec![
MigrationEntry {
path: PathBuf::from("cli/quickstart.md"),
status: MigrationStatus::NoGeneratedEquivalent,
curated: true,
},
MigrationEntry {
path: PathBuf::from("rust/topic/a.md"),
status: MigrationStatus::Identical,
curated: false,
},
]
);
}
fn generated(path: &str, content: &str) -> GeneratedFile {
GeneratedFile {
path: PathBuf::from(path),
content: content.into(),
generated_header: false,
}
}
}