use std::collections::BTreeSet;
use std::num::NonZeroUsize;
use std::path::Path;
use crate::departures::{self, Context, Pair};
pub use crate::departures::Side;
use crate::strip_python_comments;
pub use crate::departures::FERN_METADATA;
pub const PROVENANCE_FILE: &str = ".crozier-fern-golden.json";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct FileDeparture {
pub line: NonZeroUsize,
pub id: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileComparison {
pub departures: Vec<FileDeparture>,
pub residual: Option<(String, String)>,
}
impl FileComparison {
#[must_use]
pub fn matches(&self) -> bool {
self.residual.is_none()
}
#[must_use]
pub fn diff(&self) -> Option<String> {
self.residual
.as_ref()
.and_then(|(reference, crozier)| unified_diff(reference, crozier))
}
}
fn mechanics(rel: &str, text: &str) -> String {
if rel.ends_with(".py") {
strip_python_comments(text)
} else {
text.to_string()
}
}
fn verdict_text<'t>(rel: &str, text: &'t str) -> &'t str {
if rel != "__init__.py" && !rel.ends_with("/__init__.py") {
return text;
}
let mut rest = text;
while let Some((line, after)) = rest.split_once('\n') {
if !line.trim().is_empty() {
break;
}
rest = after;
}
rest
}
pub fn compare_file(
context: &Context,
rel: &str,
crozier: &str,
reference: &str,
) -> Result<FileComparison, String> {
let (crozier, reference) = (mechanics(rel, crozier), mechanics(rel, reference));
if verdict_text(rel, &crozier) == verdict_text(rel, &reference) {
return Ok(FileComparison {
departures: Vec::new(),
residual: None,
});
}
let fern: Vec<&str> = reference.split('\n').collect();
let original: Vec<&str> = crozier.split('\n').collect();
let pair = Pair {
rel,
fern: &fern,
crozier: &original,
context,
};
let mut departures = Vec::new();
let mut lines: Vec<(&str, Option<usize>)> = original
.iter()
.enumerate()
.map(|(j, line)| (*line, Some(j)))
.collect();
for (id, rule) in rules_of(|rule| rule.region) {
if let Some(region) = rule(&pair)? {
lines.splice(
region.crozier.clone(),
region.fern.clone().map(|i| (fern[i], None)),
);
departures.push(FileDeparture {
line: NonZeroUsize::MIN.saturating_add(region.crozier.start),
id,
});
break;
}
}
let line_rules = rules_of(|rule| rule.line);
let added_rules = rules_of(|rule| rule.added);
let added_at_rules = rules_of(|rule| rule.added_at);
let current: Vec<&str> = lines.iter().map(|(line, _)| *line).collect();
let line_rule = |i: usize, j: usize| -> Option<(&'static str, usize)> {
let origin = lines[j].1?;
line_rules
.iter()
.find(|(_, holds)| holds(&pair, fern[i], current[j]))
.map(|(id, _)| (*id, origin))
};
let added_rule = |j: usize| -> Option<(&'static str, usize)> {
let origin = lines[j].1?;
added_rules
.iter()
.find(|(_, holds)| holds(&pair, current[j]))
.map(|(id, _)| *id)
.or_else(|| {
added_at_rules
.iter()
.find(|(_, holds)| holds(&pair, origin))
.map(|(id, _)| *id)
})
.map(|id| (id, origin))
};
let mut result: Vec<&str> = Vec::with_capacity(current.len());
let ops = align(fern.len(), current.len(), |i, j| fern[i] == current[j]);
let mut index = 0;
while index < ops.len() {
if let Op::Equal(_, j) = ops[index] {
result.push(current[j]);
index += 1;
continue;
}
let (mut removed, mut added) = (Vec::new(), Vec::new());
while let Some(op) = ops.get(index) {
match *op {
Op::Delete(i) => removed.push(i),
Op::Insert(j) => added.push(j),
Op::Equal(..) => break,
}
index += 1;
}
let inner = align(removed.len(), added.len(), |a, b| {
line_rule(removed[a], added[b]).is_some()
});
for op in inner {
let (claimed, kept) = match op {
Op::Equal(a, b) => (line_rule(removed[a], added[b]), Some(fern[removed[a]])),
Op::Delete(_) => (None, None),
Op::Insert(b) => match added_rule(added[b]) {
Some(claimed) => (Some(claimed), None),
None => (None, Some(current[added[b]])),
},
};
if let Some((id, origin)) = claimed {
departures.push(FileDeparture {
line: NonZeroUsize::MIN.saturating_add(origin),
id,
});
}
result.extend(kept);
}
}
departures.sort();
let applied = result.join("\n");
let (reference, applied) = (verdict_text(rel, &reference), verdict_text(rel, &applied));
Ok(FileComparison {
departures,
residual: (reference != applied).then(|| (reference.to_string(), applied.to_string())),
})
}
#[must_use]
pub fn file_departure(context: &Context, rel: &str, side: Side) -> Option<&'static str> {
rules_of(|rule| rule.file)
.into_iter()
.find(|(_, holds)| holds(context, rel, side))
.map(|(id, _)| id)
}
fn rules_of<F>(shape: impl Fn(departures::Rule) -> Option<F>) -> Vec<(&'static str, F)> {
departures::RULE_IDS
.iter()
.filter(|id| departures::find(id).is_some())
.filter_map(|id| shape(departures::rule(id)?).map(|found| (*id, found)))
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Op {
Equal(usize, usize),
Delete(usize),
Insert(usize),
}
fn align(n: usize, m: usize, pairs: impl Fn(usize, usize) -> bool) -> Vec<Op> {
let mut prefix = 0;
while prefix < n && prefix < m && pairs(prefix, prefix) {
prefix += 1;
}
let mut suffix = 0;
while suffix < n - prefix && suffix < m - prefix && pairs(n - 1 - suffix, m - 1 - suffix) {
suffix += 1;
}
let (rows, cols) = (n - prefix - suffix, m - prefix - suffix);
let mut ops: Vec<Op> = (0..prefix).map(|k| Op::Equal(k, k)).collect();
let mut lcs = vec![vec![0u32; cols + 1]; rows + 1];
for i in (0..rows).rev() {
for j in (0..cols).rev() {
lcs[i][j] = if pairs(prefix + i, prefix + j) {
lcs[i + 1][j + 1] + 1
} else {
lcs[i + 1][j].max(lcs[i][j + 1])
};
}
}
let (mut i, mut j) = (0usize, 0usize);
while i < rows && j < cols {
if lcs[i][j] == lcs[i + 1][j + 1] + 1 && pairs(prefix + i, prefix + j) {
ops.push(Op::Equal(prefix + i, prefix + j));
i += 1;
j += 1;
} else if lcs[i + 1][j] >= lcs[i][j + 1] {
ops.push(Op::Delete(prefix + i));
i += 1;
} else {
ops.push(Op::Insert(prefix + j));
j += 1;
}
}
ops.extend((i..rows).map(|i| Op::Delete(prefix + i)));
ops.extend((j..cols).map(|j| Op::Insert(prefix + j)));
ops.extend((0..suffix).map(|k| Op::Equal(n - suffix + k, m - suffix + k)));
ops
}
#[must_use]
pub fn unified_diff(reference: &str, crozier: &str) -> Option<String> {
if reference == crozier {
return None;
}
const CONTEXT: usize = 3;
let a: Vec<&str> = reference.lines().collect();
let b: Vec<&str> = crozier.lines().collect();
let ops: Vec<(char, &str)> = align(a.len(), b.len(), |i, j| a[i] == b[j])
.into_iter()
.map(|op| match op {
Op::Equal(i, _) => (' ', a[i]),
Op::Delete(i) => ('-', a[i]),
Op::Insert(j) => ('+', b[j]),
})
.collect();
let keep: Vec<bool> = (0..ops.len())
.map(|k| {
let lo = k.saturating_sub(CONTEXT);
let hi = (k + CONTEXT).min(ops.len() - 1);
(lo..=hi).any(|x| ops[x].0 != ' ')
})
.collect();
let mut out = String::new();
let mut elided = 0usize;
for (k, (sign, line)) in ops.iter().enumerate() {
if keep[k] {
if elided > 0 {
out.push_str(&format!(" â‹® ({elided} unchanged line(s))\n"));
elided = 0;
}
out.push(*sign);
out.push(' ');
out.push_str(line);
out.push('\n');
} else {
elided += 1;
}
}
if elided > 0 {
out.push_str(&format!(" â‹® ({elided} unchanged line(s))\n"));
}
Some(out)
}
pub fn walk_files(root: &Path) -> Result<Vec<String>, String> {
fn rec(base: &Path, dir: &Path, out: &mut Vec<String>) -> Result<(), String> {
let mut entries = Vec::new();
let directory = std::fs::read_dir(dir)
.map_err(|error| format!("read_dir {}: {error}", dir.display()))?;
for entry in directory {
entries.push(
entry
.map_err(|error| format!("read_dir entry in {}: {error}", dir.display()))?
.path(),
);
}
entries.sort();
for path in entries {
let metadata = std::fs::symlink_metadata(&path)
.map_err(|error| format!("metadata {}: {error}", path.display()))?;
if metadata.file_type().is_symlink() {
return Err(format!(
"refusing to follow symbolic link while walking {}",
path.display()
));
}
if metadata.is_dir() {
rec(base, &path, out)?;
} else {
let rel = path.strip_prefix(base).map_err(|error| {
format!(
"{} is not below {}: {error}",
path.display(),
base.display()
)
})?;
let rel = rel.to_string_lossy().replace('\\', "/");
if rel != PROVENANCE_FILE {
out.push(rel);
}
}
}
Ok(())
}
let mut out = Vec::new();
if root.is_symlink() {
return Err(format!(
"refusing to follow symbolic link while walking {}",
root.display()
));
}
if root.is_dir() {
rec(root, root, &mut out)?;
}
Ok(out)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Difference {
OnlyInReference,
OnlyInCrozier,
Text(Option<String>),
Binary {
reference: usize,
crozier: usize,
},
Processing(String),
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct AppliedDeparture {
pub file: String,
pub location: Location,
pub id: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Location {
WholeFile,
Line(NonZeroUsize),
}
impl Location {
#[must_use]
pub fn as_number(self) -> usize {
match self {
Location::WholeFile => 0,
Location::Line(line) => line.get(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TreeComparison {
pub differences: Vec<(String, Difference)>,
pub departures: Vec<AppliedDeparture>,
}
pub fn compare_trees(
reference_root: &Path,
crozier_root: &Path,
file_filter: Option<&str>,
include_text_diffs: bool,
) -> Result<TreeComparison, String> {
compare_trees_with_document(
reference_root,
crozier_root,
file_filter,
include_text_diffs,
None,
)
}
pub(crate) fn compare_trees_with_document(
reference_root: &Path,
crozier_root: &Path,
file_filter: Option<&str>,
include_text_diffs: bool,
source: Option<crate::openapi::OpenApi>,
) -> Result<TreeComparison, String> {
let reference_files: BTreeSet<String> = walk_files(reference_root)?.into_iter().collect();
if reference_files.is_empty() && file_filter.is_none() {
return Err(format!(
"no reference files under {} — the tree walk is broken",
reference_root.display()
));
}
let crozier_files: BTreeSet<String> = walk_files(crozier_root)?.into_iter().collect();
let context = match source {
Some(source) => {
Context::from_trees(reference_root, crozier_root).with_source_document(source)
}
None => Context::from_trees(reference_root, crozier_root),
};
let paths: BTreeSet<&String> = reference_files.union(&crozier_files).collect();
let mut comparison = TreeComparison::default();
for rel in paths {
if file_filter.is_some_and(|filter| !rel.contains(filter)) {
continue;
}
let only = match (reference_files.contains(rel), crozier_files.contains(rel)) {
(true, false) => Some((Side::Reference, Difference::OnlyInReference)),
(false, true) => Some((Side::Crozier, Difference::OnlyInCrozier)),
_ => None,
};
let difference = match only {
Some((side, difference)) => match file_departure(&context, rel, side) {
Some(id) => {
comparison.departures.push(AppliedDeparture {
file: rel.clone(),
location: Location::WholeFile,
id: id.to_string(),
});
None
}
None => Some(difference),
},
_ => {
let (difference, departures) = file_difference(
&context,
rel,
&reference_root.join(rel),
&crozier_root.join(rel),
include_text_diffs,
);
comparison
.departures
.extend(departures.into_iter().map(|departure| AppliedDeparture {
file: rel.clone(),
location: Location::Line(departure.line),
id: departure.id.to_string(),
}));
difference
}
};
if let Some(difference) = difference {
comparison.differences.push((rel.clone(), difference));
}
}
Ok(comparison)
}
fn file_difference(
context: &Context,
rel: &str,
reference_path: &Path,
crozier_path: &Path,
include_text_diffs: bool,
) -> (Option<Difference>, Vec<FileDeparture>) {
let processing = |message: String| (Some(Difference::Processing(message)), Vec::new());
let reference = match std::fs::read(reference_path) {
Ok(content) => content,
Err(error) => return processing(format!("could not read the reference file: {error}")),
};
let crozier = match std::fs::read(crozier_path) {
Ok(content) => content,
Err(error) => return processing(format!("could not read crozier's file: {error}")),
};
if reference == crozier {
return (None, Vec::new());
}
let (Ok(reference_text), Ok(crozier_text)) = (
std::str::from_utf8(&reference),
std::str::from_utf8(&crozier),
) else {
return (
Some(Difference::Binary {
reference: reference.len(),
crozier: crozier.len(),
}),
Vec::new(),
);
};
match compare_file(context, rel, crozier_text, reference_text) {
Ok(compared) => {
let difference = (!compared.matches()).then(|| {
Difference::Text(include_text_diffs.then(|| compared.diff().unwrap_or_default()))
});
(difference, compared.departures)
}
Err(error) => processing(error),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn write(root: &Path, rel: &str, content: &[u8]) {
let path = root.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, content).unwrap();
}
fn compare(rel: &str, crozier: &str, reference: &str) -> FileComparison {
compare_file(&Context::default(), rel, crozier, reference).unwrap()
}
fn ids(compared: &FileComparison) -> Vec<(usize, &'static str)> {
compared
.departures
.iter()
.map(|departure| (departure.line.get(), departure.id))
.collect()
}
const WRAPPER: &str = "src/acme/core/client_wrapper.py";
#[test]
fn identity_headers_are_departures_line_by_line() {
let crozier = concat!(
"# crozier\nh = {\n \"X-Crozier-Language\": \"Python\",\n ",
"\"X-Crozier-SDK-Name\": \"x\",\n \"X-Crozier-SDK-Version\": \"0.0.0\",\n}\n"
);
let reference = concat!(
"\nh = {\n \"X-Fern-Language\": \"Python\",\n ",
"\"X-Fern-SDK-Name\": \"x\",\n \"X-Fern-SDK-Version\": \"0.0.0\",\n}\n"
);
let context = Context::from_sources([], [("pyproject.toml", "name = \"x\"\n")]);
let compare = |rel: &str, crozier: &str, reference: &str| {
compare_file(&context, rel, crozier, reference).unwrap()
};
let compared = compare(WRAPPER, crozier, reference);
assert!(compared.matches(), "{:?}", compared.diff());
assert_eq!(
ids(&compared),
[
(3, "sdk-identity-header-prefix"),
(4, "sdk-identity-header-prefix"),
(5, "sdk-identity-header-prefix"),
]
);
let unpackaged = "\nh = {\n \"X-Fern-Language\": \"Python\",\n}\n";
let compared = compare(WRAPPER, crozier, unpackaged);
assert!(compared.matches(), "{:?}", compared.diff());
assert_eq!(
ids(&compared),
[
(3, "sdk-identity-header-prefix"),
(4, "sdk-name-version-headers"),
(5, "sdk-name-version-headers"),
]
);
let released = reference.replace("\"0.0.0\"", "\"2.3.1\"");
let compared = compare(WRAPPER, crozier, &released);
assert!(compared.matches(), "{:?}", compared.diff());
assert_eq!(
ids(&compared),
[
(3, "sdk-identity-header-prefix"),
(4, "sdk-identity-header-prefix"),
(5, "sdk-name-version-headers"),
]
);
let wrong = crozier.replace("\"0.0.0\"", "\"9.9.9\"");
let compared = compare(WRAPPER, &wrong, &released);
assert!(!compared.matches());
assert!(
compared
.diff()
.unwrap()
.contains("+ \"X-Crozier-SDK-Version\": \"9.9.9\","),
"{:?}",
compared.diff()
);
assert!(!compare(WRAPPER, &wrong, unpackaged).matches());
}
#[test]
fn a_difference_no_rule_explains_still_fails_beside_a_departure() {
let crozier = "h = {\n \"X-Crozier-Language\": \"Python\",\n}\nx = 1\n";
let same_line = "h = {\n \"X-Fern-Language\": \"Pythn\",\n}\nx = 1\n";
let compared = compare(WRAPPER, crozier, same_line);
assert!(!compared.matches());
assert!(compared.departures.is_empty());
let diff = compared.diff().unwrap();
assert!(
diff.contains("- \"X-Fern-Language\": \"Pythn\","),
"{diff}"
);
let elsewhere = "h = {\n \"X-Fern-Language\": \"Python\",\n}\nx = 2\n";
let compared = compare(WRAPPER, crozier, elsewhere);
assert!(!compared.matches());
assert_eq!(ids(&compared), [(2, "sdk-identity-header-prefix")]);
let diff = compared.diff().unwrap();
assert!(
diff.contains("- x = 2") && diff.contains("+ x = 1"),
"{diff}"
);
assert!(!diff.contains("X-Crozier"), "{diff}");
let compared = compare(
"src/acme/client.py",
crozier,
&crozier.replace("X-Crozier-", "X-Fern-"),
);
assert!(!compared.matches() && compared.departures.is_empty());
}
#[test]
fn comments_and_init_leading_blank_lines_are_mechanics_not_departures() {
let compared = compare("a/b.py", "x = 1 # crozier\n", "x = 1 # reference\n");
assert!(compared.matches() && compared.departures.is_empty());
let compared = compare(
"pkg/__init__.py",
"# header\nimport a\n",
"# other header\n\n# isort: skip_file\n\nimport a\n",
);
assert!(compared.matches() && compared.departures.is_empty());
assert!(!compare("pkg/client.py", "# h\nimport a\n", "\n\nimport a\n").matches());
assert!(!compare("__init__.py", "import a\n\nx\n", "import a\nx\n").matches());
assert!(compare("__init__.py", "\n", "").matches());
assert!(!compare("README.md", "# Title\n", "# Other\n").matches());
}
#[test]
fn init_import_order_is_one_departure_at_crozier_s_first_moved_line() {
let crozier = concat!(
"# crozier\nimport typing\n\nif typing.TYPE_CHECKING:\n from .a import A\n ",
"from .b import B\n from .c import C\n_dynamic_imports = {}\n"
);
let reference = concat!(
"\n\n\nimport typing\n\nif typing.TYPE_CHECKING:\n from .b import B\n ",
"from .a import A\n from .c import C\n_dynamic_imports = {}\n"
);
let compared = compare("src/acme/__init__.py", crozier, reference);
assert!(compared.matches(), "{:?}", compared.diff());
assert_eq!(ids(&compared), [(5, "init-type-checking-import-order")]);
let other = reference.replace("_dynamic_imports = {}", "_dynamic_imports = {1: 2}");
let compared = compare("src/acme/__init__.py", crozier, &other);
assert!(!compared.matches());
let other = reference.replace("from .c import C", "from .c import D");
let compared = compare("src/acme/__init__.py", crozier, &other);
assert!(!compared.matches() && compared.departures.is_empty());
}
#[test]
fn init_import_order_admits_reordered_statements_beside_another_difference() {
let block = |types: &str, order: [&str; 4]| {
format!(
"import typing\n\nif typing.TYPE_CHECKING:\n from .types import {types}\n{}_dynamic_imports = {{}}\n",
order.iter().map(|line| format!(" {line}\n")).collect::<String>()
)
};
let (auth, client, environment, user) = (
"from .auth import (\n AuthMeResponse,\n )",
"from .client import AsyncFernApi, FernApi",
"from .environment import FernApiEnvironment",
"from .user import UserIndexResponse",
);
let crozier = block("Account", [client, environment, auth, user]);
let reference = block("Account", [auth, client, environment, user]);
let compared = compare("src/fern/__init__.py", &crozier, &reference);
assert!(compared.matches(), "{:?}", compared.diff());
assert_eq!(ids(&compared), [(5, "init-type-checking-import-order")]);
let reference = block("Account, ErrorBody", [auth, client, environment, user]);
let compared = compare("src/fern/__init__.py", &crozier, &reference);
assert!(!compared.matches());
assert_eq!(ids(&compared), [(5, "init-type-checking-import-order")]);
let diff = compared.diff().expect("the name difference remains");
assert!(
diff.contains("ErrorBody") && !diff.contains("from .client"),
"{diff}"
);
let moved = auth.replace("AuthMeResponse", "AuthMeResponse,\n AuthToken");
let reference = block("Account, ErrorBody", [&moved, client, environment, user]);
let compared = compare("src/fern/__init__.py", &crozier, &reference);
assert!(!compared.matches() && compared.departures.is_empty());
let reference = block("Account, ErrorBody", [user, client, environment, auth]);
let compared = compare("src/fern/__init__.py", &crozier, &reference);
assert!(!compared.matches() && compared.departures.is_empty());
let reference = block(
"Account, ErrorBody",
[auth, client, "from .zone import Zone", user],
);
let compared = compare("src/fern/__init__.py", &crozier, &reference);
assert!(!compared.matches() && compared.departures.is_empty());
}
#[test]
fn metadata_generator_config_is_one_departure_on_that_path_only() {
let crozier = crate::emit::FERN_METADATA_RECORD;
let reference = crozier.replace("python_enums", "literals");
let compared = compare(FERN_METADATA, crozier, &reference);
assert!(compared.matches(), "{:?}", compared.diff());
assert_eq!(ids(&compared), [(7, "fern-metadata-generator-config")]);
let start = crozier.find(" \"generatorConfig\"").unwrap();
let end = crozier.find(" \"invokedBy\"").unwrap();
let absent = format!("{}{}", &crozier[..start], &crozier[end..]);
let compared = compare(FERN_METADATA, crozier, &absent);
assert!(compared.matches(), "{:?}", compared.diff());
assert_eq!(ids(&compared), [(5, "fern-metadata-generator-config")]);
let wrong = crozier.replace("python_enums", "literals");
let compared = compare(FERN_METADATA, &wrong, &absent);
assert!(!compared.matches() && compared.departures.is_empty());
for rel in [
"types/user_metadata.json",
"foo/metadata.json",
"metadata.json",
"foo/.fern/metadata.json",
] {
let compared = compare(rel, crozier, &reference);
assert!(
!compared.matches() && compared.departures.is_empty(),
"{rel}"
);
}
}
#[test]
fn crozier_s_own_sdk_version_or_metadata_record_changed_is_no_departure() {
let (wrapper, metadata) = ("src/fern/core/client_wrapper.py", FERN_METADATA);
let fixture =
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/client-class-name");
let out = tempfile::tempdir().unwrap();
crate::generate(crate::GenerateArgs {
spec: fixture.join("openapi.yml"),
output: out.path().to_path_buf(),
package_name: Some("fern".into()),
project_name: Some("default_package_name".into()),
client_class_name: Some("AcmeClient".into()),
audiences: Vec::new(),
audience_strict: false,
fern_strict: false,
extra_fields: crate::settings::ExtraFields::Allow,
enum_type: crate::settings::EnumType::PythonEnums,
default_max_retries: crate::settings::DEFAULT_MAX_RETRIES,
layout: crate::settings::Layout::Packaged,
})
.unwrap();
let reference = tempfile::tempdir().unwrap();
for rel in walk_files(&fixture.join("expected")).unwrap() {
let text = std::fs::read(fixture.join("expected").join(&rel)).unwrap();
write(reference.path(), &rel, &text);
}
let edit = |root: &Path, rel: &str, from: &str, to: &str| {
let text = std::fs::read_to_string(root.join(rel)).unwrap();
assert!(text.contains(from), "{rel} holds no {from:?}");
std::fs::write(root.join(rel), text.replacen(from, to, 1)).unwrap();
};
edit(
reference.path(),
wrapper,
"\"X-Fern-SDK-Version\": \"0.0.0\"",
"\"X-Fern-SDK-Version\": \"2.3.1\"",
);
let compare = || compare_trees(reference.path(), out.path(), None, true).unwrap();
let differing = |compared: &TreeComparison| -> Vec<String> {
compared
.differences
.iter()
.map(|(rel, _)| rel.clone())
.collect()
};
let applied = |compared: &TreeComparison, id: &str, file: &str| {
compared
.departures
.iter()
.any(|departure| departure.id == id && departure.file == file)
};
let compared = compare();
assert!(compared.differences.is_empty(), "{compared:?}");
assert!(applied(&compared, "sdk-name-version-headers", wrapper));
assert!(applied(
&compared,
"fern-metadata-generator-config",
metadata
));
edit(
out.path(),
wrapper,
"\"X-Crozier-SDK-Version\": \"0.0.0\"",
"\"X-Crozier-SDK-Version\": \"9.9.9\"",
);
let compared = compare();
assert_eq!(differing(&compared), [wrapper]);
assert!(!applied(&compared, "sdk-name-version-headers", wrapper));
edit(out.path(), wrapper, "\"9.9.9\"", "\"0.0.0\"");
edit(out.path(), metadata, "\"python_enums\"", "\"literals\"");
let compared = compare();
assert_eq!(differing(&compared), [metadata]);
assert!(!applied(
&compared,
"fern-metadata-generator-config",
metadata
));
}
#[test]
fn readme_casing_needs_the_trees_classes() {
let crozier = "# Acme\n\nfrom LanternHarbor import AsyncLanternHarborApi\n";
let reference = "# Acme\n\nfrom LanternHarbor import AsyncLanternharborApi\n";
assert!(!compare("README.md", crozier, reference).matches());
let context = Context::from_sources(
[("client.py", "class AsyncLanternHarborApi:\n")],
[("client.py", "class AsyncLanternHarborApi:\n")],
);
let compared = compare_file(&context, "README.md", crozier, reference).unwrap();
assert!(compared.matches(), "{:?}", compared.diff());
assert_eq!(ids(&compared), [(3, "readme-client-class-casing")]);
}
#[test]
fn a_rule_that_cannot_run_is_an_error() {
let error = compare_file(
&Context::default(),
"__init__.py",
"if typing.TYPE_CHECKING:\n def (:\n import b\nx\n",
"if typing.TYPE_CHECKING:\n import b\n def (:\nx\n",
)
.unwrap_err();
assert!(error.contains("ruff isort failed"), "{error}");
}
#[test]
fn alignment_pairs_the_longest_common_run() {
let a = ["a", "b", "c", "d"];
let b = ["a", "x", "c", "d", "e"];
let ops = align(a.len(), b.len(), |i, j| a[i] == b[j]);
assert_eq!(
ops,
[
Op::Equal(0, 0),
Op::Delete(1),
Op::Insert(1),
Op::Equal(2, 2),
Op::Equal(3, 3),
Op::Insert(4),
]
);
assert_eq!(align(0, 1, |_, _| true), [Op::Insert(0)]);
assert_eq!(align(1, 0, |_, _| true), [Op::Delete(0)]);
let (a, b) = (["x", "a", "y"], ["a", "x"]);
assert_eq!(
align(a.len(), b.len(), |i, j| a[i] == b[j]),
[Op::Delete(0), Op::Equal(1, 0), Op::Delete(2), Op::Insert(1)]
);
}
#[test]
fn unified_diff_reports_only_real_changes() {
assert_eq!(unified_diff("a\nb\nc", "a\nb\nc"), None);
let d = unified_diff("a\nb\nc", "a\nB\nc").expect("differs");
assert!(d.contains("- b") && d.contains("+ B"), "{d}");
assert!(d.contains(" a") && d.contains(" c"), "{d}");
let d = unified_diff("a", "a\nb").unwrap();
assert!(d.contains("+ b"), "{d}");
let d = unified_diff("a\nb", "a").unwrap();
assert!(d.contains("- b"), "{d}");
let big = (0..40).map(|n| n.to_string()).collect::<Vec<_>>();
let mut changed = big.clone();
changed[0] = "first".into();
changed[39] = "last".into();
let d = unified_diff(&big.join("\n"), &changed.join("\n")).unwrap();
assert!(d.contains("unchanged line(s)"), "{d}");
assert!(!d.contains("\n 20\n"), "{d}");
let mut head_only = big.clone();
head_only[0] = "first".into();
let d = unified_diff(&big.join("\n"), &head_only.join("\n")).unwrap();
assert!(d.ends_with("unchanged line(s))\n"), "{d}");
}
#[test]
fn compare_trees_accounts_for_moved_files_under_a_file_rule() {
let reference = tempfile::tempdir().unwrap();
let crozier = tempfile::tempdir().unwrap();
let (r, c) = (reference.path(), crozier.path());
let raw = b"from ..core.client_wrapper import SyncClientWrapper\n\n\nclass RawClient:\n pass\n";
write(
r,
"src/fern/client.py",
b"from ..core.client_wrapper import SyncClientWrapper\n\n\nclass Client:\n pass\n",
);
write(r, "src/fern/raw_client.py", raw);
write(r, "src/fern/__init__.py", b"\n");
write(c, "src/fern/client.py", b"class FernApi:\n pass\n");
write(c, "src/fern/__init__.py", b"from .client import FernApi\n");
write(
c,
"src/fern/_/client.py",
b"from ..core.client_wrapper import SyncClientWrapper\n\n\nclass Client:\n pass\n",
);
write(c, "src/fern/_/raw_client.py", raw);
write(c, "src/fern/_/__init__.py", b"\n");
let found = compare_trees(r, c, None, false).unwrap();
assert!(found.differences.is_empty(), "{found:?}");
let applied: Vec<(&str, usize)> = found
.departures
.iter()
.map(|departure| {
assert_eq!(departure.id, "empty-namespace-package");
(departure.file.as_str(), departure.location.as_number())
})
.collect();
assert_eq!(
applied,
[
("src/fern/_/__init__.py", 0),
("src/fern/_/client.py", 0),
("src/fern/_/raw_client.py", 0),
("src/fern/__init__.py", 1),
("src/fern/client.py", 1),
("src/fern/raw_client.py", 0),
]
);
write(c, "src/fern/_/raw_client.py", b"class Other:\n pass\n");
let found = compare_trees(r, c, None, false).unwrap();
assert!(found.departures.is_empty(), "{found:?}");
assert!(found.differences.contains(&(
"src/fern/raw_client.py".to_string(),
Difference::OnlyInReference
)));
}
#[test]
fn compare_trees_is_bidirectional_skips_provenance_and_reports_departures() {
let reference = tempfile::tempdir().unwrap();
let crozier = tempfile::tempdir().unwrap();
let (r, c) = (reference.path(), crozier.path());
write(r, "same.py", b"x = 1 # a\n");
write(c, "same.py", b"x = 1 # b\n");
write(r, "changed.txt", b"reference\n");
write(c, "changed.txt", b"crozier\n");
write(r, "dir/only-reference.txt", b"r\n");
write(c, "only-crozier.txt", b"c\n");
write(r, "bin.dat", &[0xff, 0x00]);
write(c, "bin.dat", &[0xfe]);
write(r, "identical.bin", &[0xff]);
write(c, "identical.bin", &[0xff]);
write(r, PROVENANCE_FILE, b"{}");
write(
r,
"core/client_wrapper.py",
b"h = {\n \"X-Fern-Language\": \"Python\",\n}\n",
);
write(
c,
"core/client_wrapper.py",
b"h = {\n \"X-Crozier-Language\": \"Python\",\n}\n",
);
let found = compare_trees(r, c, None, true).unwrap();
let paths: Vec<&str> = found.differences.iter().map(|(p, _)| p.as_str()).collect();
assert_eq!(
paths,
[
"bin.dat",
"changed.txt",
"dir/only-reference.txt",
"only-crozier.txt"
]
);
assert_eq!(
found.differences[0].1,
Difference::Binary {
reference: 2,
crozier: 1
}
);
let Difference::Text(Some(diff)) = &found.differences[1].1 else {
panic!("{found:?}");
};
assert!(diff.contains("- reference") && diff.contains("+ crozier"));
assert_eq!(found.differences[2].1, Difference::OnlyInReference);
assert_eq!(found.differences[3].1, Difference::OnlyInCrozier);
assert_eq!(
found.departures,
[AppliedDeparture {
file: "core/client_wrapper.py".into(),
location: Location::Line(NonZeroUsize::new(2).unwrap()),
id: "sdk-identity-header-prefix".into(),
}]
);
let summary = compare_trees(r, c, Some("changed"), false).unwrap();
assert_eq!(
summary.differences,
[("changed.txt".to_string(), Difference::Text(None))]
);
assert!(summary.departures.is_empty());
}
#[test]
fn compare_trees_reads_each_tree_s_classes_for_the_casing_rule() {
let reference = tempfile::tempdir().unwrap();
let crozier = tempfile::tempdir().unwrap();
let (r, c) = (reference.path(), crozier.path());
for root in [r, c] {
write(root, "client.py", b"class LanternHarborApi:\n pass\n");
}
write(
r,
"README.md",
b"from LanternHarbor import LanternharborApi\n",
);
write(
c,
"README.md",
b"from LanternHarbor import LanternHarborApi\n",
);
let found = compare_trees(r, c, None, true).unwrap();
assert!(found.differences.is_empty(), "{found:?}");
assert_eq!(
found.departures,
[AppliedDeparture {
file: "README.md".into(),
location: Location::Line(NonZeroUsize::MIN),
id: "readme-client-class-casing".into(),
}]
);
}
#[test]
fn compare_trees_reports_a_pair_a_rule_cannot_run_on() {
let reference = tempfile::tempdir().unwrap();
let crozier = tempfile::tempdir().unwrap();
write(
reference.path(),
"__init__.py",
b"if typing.TYPE_CHECKING:\n import b\n def (:\nx\n",
);
write(
crozier.path(),
"__init__.py",
b"if typing.TYPE_CHECKING:\n def (:\n import b\nx\n",
);
let found = compare_trees(reference.path(), crozier.path(), None, true).unwrap();
assert!(
matches!(&found.differences[0].1, Difference::Processing(e) if e.contains("ruff")),
"{found:?}"
);
}
#[test]
fn an_empty_reference_is_never_a_match() {
let reference = tempfile::tempdir().unwrap();
let crozier = tempfile::tempdir().unwrap();
let error = compare_trees(reference.path(), crozier.path(), None, true).unwrap_err();
assert!(error.contains("no reference files"), "{error}");
assert!(walk_files(&reference.path().join("absent"))
.unwrap()
.is_empty());
}
#[cfg(unix)]
#[test]
fn symbolic_links_are_refused_not_followed() {
use std::os::unix::fs::symlink;
let reference = tempfile::tempdir().unwrap();
let crozier = tempfile::tempdir().unwrap();
let outside = tempfile::NamedTempFile::new().unwrap();
symlink(outside.path(), reference.path().join("link")).unwrap();
let error = compare_trees(reference.path(), crozier.path(), None, true).unwrap_err();
assert!(
error.contains("refusing to follow symbolic link"),
"{error}"
);
let root_link = reference.path().join("root-link");
symlink(crozier.path(), &root_link).unwrap();
let error = walk_files(&root_link).unwrap_err();
assert!(
error.contains("refusing to follow symbolic link"),
"{error}"
);
}
#[cfg(unix)]
#[test]
fn an_unreadable_file_is_a_processing_difference() {
use std::os::unix::fs::PermissionsExt;
let reference = tempfile::tempdir().unwrap();
let crozier = tempfile::tempdir().unwrap();
write(reference.path(), "a.txt", b"a\n");
write(crozier.path(), "a.txt", b"a\n");
let locked = crozier.path().join("a.txt");
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
let found = compare_trees(reference.path(), crozier.path(), None, true).unwrap();
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o644)).unwrap();
if let Some((_, difference)) = found.differences.first() {
assert!(
matches!(difference, Difference::Processing(e) if e.contains("crozier's file")),
"{found:?}"
);
}
std::fs::set_permissions(
reference.path().join("a.txt"),
std::fs::Permissions::from_mode(0o000),
)
.unwrap();
let found = compare_trees(reference.path(), crozier.path(), None, true).unwrap();
if let Some((_, difference)) = found.differences.first() {
assert!(
matches!(difference, Difference::Processing(e) if e.contains("reference file")),
"{found:?}"
);
}
}
#[test]
fn stripping_a_committed_golden_again_is_a_no_op() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let mut trees = Vec::new();
for fixture in std::fs::read_dir(root.join("tests/fixtures")).unwrap() {
let fixture = fixture.unwrap().path();
for golden in ["expected", "expected-flat"] {
if fixture.join(golden).is_dir() {
trees.push(fixture.join(golden));
}
}
}
trees.push(root.join("docs/openapi-surface/probe-expected"));
let mut checked = 0usize;
let mut changed = Vec::new();
for tree in &trees {
for rel in walk_files(tree).unwrap() {
if !rel.ends_with(".py") {
continue;
}
let text = std::fs::read_to_string(tree.join(&rel)).unwrap();
if strip_python_comments(&text) != text {
changed.push(tree.join(&rel).display().to_string());
}
checked += 1;
}
}
assert!(checked > 1000, "only {checked} golden modules found");
assert!(changed.is_empty(), "stripping changed: {changed:?}");
}
}