#![cfg_attr(docsrs, feature(doc_cfg))]
#[cfg(feature = "predicates")]
mod feature_predicates;
#[cfg(feature = "predicates")]
pub use feature_predicates::*;
use atomicwrites::{AtomicFile, OverwriteBehavior};
use console::Style;
use newline_converter::dos2unix;
use similar::{Algorithm, ChangeTag, TextDiff};
use std::{env, ffi::OsStr, fs, io::Write, path::Path, time::Duration};
const DIFF_TIMEOUT: Duration = Duration::from_millis(500);
const MAX_DIFF_LINES: usize = 500;
#[derive(Clone, Copy, Debug)]
pub(crate) struct DiffLimits {
timeout: Duration,
max_lines: usize,
}
impl Default for DiffLimits {
fn default() -> Self {
Self {
timeout: DIFF_TIMEOUT,
max_lines: MAX_DIFF_LINES,
}
}
}
#[track_caller]
pub fn assert_contents<P: AsRef<Path>>(path: P, actual: &str) {
if let Err(e) = assert_contents_impl(
path,
actual,
OverwriteMode::from_env(),
DiffLimits::default(),
) {
panic!("assertion failed: {e}")
}
}
#[derive(Clone, Copy, Debug)]
pub(crate) enum OverwriteMode {
Check,
Overwrite,
}
impl OverwriteMode {
pub(crate) fn from_env() -> Self {
let var = env::var_os("EXPECTORATE");
if var.as_deref().and_then(OsStr::to_str) == Some("overwrite") {
OverwriteMode::Overwrite
} else {
OverwriteMode::Check
}
}
}
pub(crate) fn assert_contents_impl<P: AsRef<Path>>(
path: P,
actual: &str,
mode: OverwriteMode,
limits: DiffLimits,
) -> Result<(), String> {
let path = path.as_ref();
let actual = dos2unix(actual);
let current = match fs::read_to_string(path) {
Ok(s) => Some(s),
Err(e) => match e.kind() {
std::io::ErrorKind::NotFound => None,
_ => panic!("unable to read contents of {}: {}", path.display(), e),
},
};
match mode {
OverwriteMode::Overwrite => {
if current.as_deref() != Some(&actual) {
let behavior = if current.is_some() {
OverwriteBehavior::AllowOverwrite
} else {
OverwriteBehavior::DisallowOverwrite
};
let f = AtomicFile::new(path, behavior);
let res = f.write(|f| {
f.write(actual.as_bytes())
});
if let Err(e) = res {
panic!("unable to write to {}: {}", path.display(), e);
}
}
}
OverwriteMode::Check => {
let expected_s = current.unwrap_or_default();
let expected = dos2unix(&expected_s);
if expected != actual {
let mut printed = 0;
let mut truncated = false;
for hunk in TextDiff::configure()
.algorithm(Algorithm::Myers)
.timeout(limits.timeout)
.diff_lines(&expected, &actual)
.unified_diff()
.context_radius(5)
.iter_hunks()
{
println!("{}", hunk.header());
printed += 1 + hunk.iter_changes().count();
if printed >= limits.max_lines {
println!("<remaining output too large>");
truncated = true;
break;
}
for change in hunk.iter_changes() {
let (marker, style) = match change.tag() {
ChangeTag::Delete => ('-', Style::new().red()),
ChangeTag::Insert => ('+', Style::new().green()),
ChangeTag::Equal => (' ', Style::new()),
};
print!("{}", style.apply_to(marker).bold());
print!("{}", style.apply_to(change));
if change.missing_newline() {
println!();
}
}
}
println!();
let truncation = if truncated {
format!(
"\n diff truncated after {} lines; \
overwrite and use e.g. `git diff` to see the whole \
change",
limits.max_lines,
)
} else {
String::new()
};
return Err(format!(
r#"string doesn't match the contents of file: "{}" see diffset above{}
set EXPECTORATE=overwrite if these changes are intentional"#,
path.display(),
truncation,
));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use filetime::{set_file_mtime, FileTime};
use std::ops::Range;
use tempfile::TempDir;
fn lines(range: Range<usize>, value: u64) -> String {
range
.map(|i| format!("line {i} of value {value}\n"))
.collect()
}
fn lines_changing(n: usize, changed: impl Fn(usize) -> bool) -> String {
(0..n)
.map(|i| {
if changed(i) {
format!("changed line {i}\n")
} else {
format!("line {i} of value 1\n")
}
})
.collect()
}
fn check(expected: &str, actual: &str, limits: DiffLimits) -> String {
let dir = TempDir::with_prefix("expectorate-").unwrap();
let path = dir.path().join("my-file.txt");
fs::write(&path, expected).unwrap();
assert_contents_impl(&path, actual, OverwriteMode::Check, limits)
.unwrap_err()
}
#[test]
fn overwite_same_mtime_doesnt_change() {
static CONTENTS: &str = "foo";
const MTIME: FileTime = FileTime::from_unix_time(946684800, 0);
let dir = TempDir::with_prefix("expectorate-").unwrap();
let path = dir.path().join("my-file.txt");
fs::write(&path, CONTENTS).unwrap();
set_file_mtime(&path, MTIME).unwrap();
assert_contents_impl(
&path,
CONTENTS,
OverwriteMode::Overwrite,
DiffLimits::default(),
)
.unwrap();
let meta = fs::metadata(&path).unwrap();
let mtime2 = FileTime::from_last_modification_time(&meta);
assert_eq!(mtime2, MTIME, "mtime is zero");
}
#[test]
fn timeout_is_applied_to_the_diff() {
const N: usize = 10_000;
let expected = lines_changing(N, |_| false);
let actual = lines_changing(N, |i| i == 5 || i == N - 5);
let exact = check(&expected, &actual, DiffLimits::default());
assert!(
!exact.contains("diff truncated"),
"two changes {N} lines apart should produce 2 small hunks: {exact}",
);
let timedout = check(
&expected,
&actual,
DiffLimits {
timeout: Duration::ZERO,
..Default::default()
},
);
assert!(
timedout.contains("diff truncated"),
"an expired deadline should produce a huge diff: {timedout}",
);
}
#[test]
fn test_output_length_limited() {
let expected = lines_changing(2_000, |_| false);
let actual = lines_changing(2_000, |i| i % 100 == 50);
let full = check(&expected, &actual, DiffLimits::default());
assert!(
!full.contains("diff truncated"),
"twenty small hunks should fit under the default cap: {full}"
);
let capped = check(
&expected,
&actual,
DiffLimits {
max_lines: 50,
..Default::default()
},
);
assert!(
capped.contains("diff truncated after 50 lines"),
"expected the cap to be reported: {capped}"
);
}
#[test]
fn wholly_different_file_is_truncated() {
let err = check(
&lines(0..50_000, 1),
&lines(0..50_000, 2),
DiffLimits::default(),
);
assert!(err.contains("diff truncated"), "unexpected error: {err}");
}
#[test]
fn large_file_small_change_is_not_truncated() {
let mut actual = lines(0..50_000, 1);
actual.push_str("one more line\n");
actual.push_str(&lines(50_000..100_000, 1));
let err = check(&lines(0..100_000, 1), &actual, DiffLimits::default());
assert!(
!err.contains("diff truncated"),
"diff should not have been truncated: {err}"
);
}
}