use exiftool_rs::ExifTool;
use std::collections::BTreeSet;
use std::panic;
use std::path::Path;
fn safe_extract(path: &Path) -> Option<Vec<exiftool_rs::Tag>> {
let path = path.to_path_buf();
let result = panic::catch_unwind(move || {
let et = ExifTool::new();
et.extract_info(&path)
});
match result {
Ok(Ok(tags)) => Some(tags),
_ => None,
}
}
type Delta = (String, &'static str, String);
const BASELINE: &str = "tests/parity_baseline.txt";
fn current_deltas() -> (BTreeSet<Delta>, usize) {
let images_dir = Path::new("tests/images");
let expected_dir = Path::new("tests/expected");
let mut entries: Vec<_> = std::fs::read_dir(images_dir)
.unwrap()
.map(|e| e.unwrap())
.collect();
entries.sort_by_key(|e| e.file_name());
let mut deltas = BTreeSet::new();
let mut tested = 0;
for entry in entries {
let file_name = entry.file_name().to_string_lossy().to_string();
let expected_path = expected_dir.join(format!("{file_name}.tags"));
if !expected_path.exists() {
continue;
}
let tags = safe_extract(&entry.path()).unwrap_or_default();
#[cfg_attr(not(windows), allow(unused_mut))]
let mut actual: BTreeSet<String> = tags.iter().map(|t| t.name.clone()).collect();
#[cfg_attr(not(windows), allow(unused_mut))]
let mut expected: BTreeSet<String> = std::fs::read_to_string(&expected_path)
.unwrap()
.lines()
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect();
#[cfg(windows)]
{
const UNIX_FS_TAGS: [&str; 4] = [
"FileModifyDate",
"FileAccessDate",
"FileInodeChangeDate",
"FilePermissions",
];
for t in UNIX_FS_TAGS {
actual.remove(t);
expected.remove(t);
}
}
for t in expected.difference(&actual) {
deltas.insert((file_name.clone(), "missing", t.clone()));
}
for t in actual.difference(&expected) {
deltas.insert((file_name.clone(), "extra", t.clone()));
}
tested += 1;
}
(deltas, tested)
}
fn fmt_delta(d: &Delta) -> String {
format!("{}\t{}\t{}", d.0, d.1, d.2)
}
fn read_baseline() -> BTreeSet<Delta> {
std::fs::read_to_string(BASELINE)
.unwrap_or_default()
.lines()
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.filter_map(|l| {
let mut it = l.splitn(3, '\t');
let file = it.next()?.to_string();
let kind = match it.next()? {
"missing" => "missing",
"extra" => "extra",
_ => return None,
};
let tag = it.next()?.to_string();
Some((file, kind, tag))
})
.collect()
}
fn write_baseline(deltas: &BTreeSet<Delta>) {
let mut out = String::from(
"# Parity baseline: known deltas between exiftool-rs and ExifTool tag names.\n\
# `regression_tag_names` fails on any NEW delta (a tag that newly disappears\n\
# = regression, or a new spurious tag). Improvements are allowed.\n\
# Regenerate: UPDATE_PARITY_BASELINE=1 cargo test --test regression regression_tag_names\n\
# Format: <file>\\t<missing|extra>\\t<tag>\n",
);
for d in deltas {
out.push_str(&fmt_delta(d));
out.push('\n');
}
std::fs::write(BASELINE, out).unwrap();
}
#[test]
fn regression_tag_names() {
let (current, tested) = current_deltas();
assert!(
tested >= 100,
"Expected to compare at least 100 files, got {tested}"
);
if std::env::var_os("UPDATE_PARITY_BASELINE").is_some() {
write_baseline(¤t);
eprintln!(
"Wrote {BASELINE}: {} known delta(s) over {tested} files.",
current.len()
);
return;
}
let baseline = read_baseline();
let regressions: Vec<_> = current.difference(&baseline).collect();
let improvements = baseline.difference(¤t).count();
if improvements > 0 {
eprintln!(
"✨ {improvements} baselined delta(s) no longer occur — tighten the net with \
`UPDATE_PARITY_BASELINE=1 cargo test --release --test regression regression_tag_names`."
);
}
if cfg!(debug_assertions) {
eprintln!(
"debug build: {} delta(s) vs baseline — not enforced. Run `cargo test --release`.",
regressions.len()
);
return;
}
assert!(
regressions.is_empty(),
"{} NEW tag-name delta(s) vs ExifTool — a regression or a new spurious tag:\n{}\n\n\
If this change is intentional, regenerate the baseline:\n \
UPDATE_PARITY_BASELINE=1 cargo test --test regression regression_tag_names",
regressions.len(),
regressions
.iter()
.map(|d| format!(" {}", fmt_delta(d)))
.collect::<Vec<_>>()
.join("\n"),
);
}
#[cfg(unix)]
const VALUE_BASELINE: &str = "tests/value_baseline.txt";
#[cfg(unix)]
fn sanitize_value(s: &str) -> String {
let mut result = String::with_capacity(s.len());
for ch in s.chars() {
if ch == '\0' {
} else if ('\u{01}'..='\u{1f}').contains(&ch) || ch == '\u{7f}' {
result.push('.');
} else {
result.push(ch);
}
}
result.trim_end().to_string()
}
#[cfg(unix)]
fn force_oracle_tz() {
use std::sync::Once;
static TZ_INIT: Once = Once::new();
TZ_INIT.call_once(|| {
std::env::set_var("TZ", "Europe/Paris");
extern "C" {
fn tzset();
}
unsafe { tzset() };
});
}
#[cfg(unix)]
fn current_value_deltas() -> (BTreeSet<(String, String)>, usize) {
use std::collections::HashMap;
force_oracle_tz();
let images_dir = Path::new("tests/images");
let expected_dir = Path::new("tests/expected_values");
let mut entries: Vec<_> = std::fs::read_dir(images_dir)
.unwrap()
.map(|e| e.unwrap())
.collect();
entries.sort_by_key(|e| e.file_name());
let mut deltas = BTreeSet::new();
let mut tested = 0;
for entry in entries {
let file_name = entry.file_name().to_string_lossy().to_string();
let vals_path = expected_dir.join(format!("{file_name}.vals"));
if !vals_path.exists() {
continue;
}
let tags = safe_extract(&entry.path()).unwrap_or_default();
let mut actual: HashMap<&str, String> = HashMap::new();
for t in &tags {
actual
.entry(t.name.as_str())
.or_insert_with(|| sanitize_value(&t.print_value));
}
let content = std::fs::read(&vals_path)
.map(|b| String::from_utf8_lossy(&b).into_owned())
.unwrap_or_default();
for line in content.lines() {
let mut it = line.splitn(2, '\t');
let (name, expected) = match (it.next(), it.next()) {
(Some(n), Some(v)) => (n, v),
_ => continue,
};
if let Some(got) = actual.get(name) {
if got.as_str() != expected {
deltas.insert((file_name.clone(), name.to_string()));
}
}
}
tested += 1;
}
(deltas, tested)
}
#[cfg(unix)]
fn read_value_baseline() -> BTreeSet<(String, String)> {
std::fs::read_to_string(VALUE_BASELINE)
.unwrap_or_default()
.lines()
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.filter_map(|l| {
let mut it = l.splitn(2, '\t');
Some((it.next()?.to_string(), it.next()?.to_string()))
})
.collect()
}
#[cfg(unix)]
fn write_value_baseline(deltas: &BTreeSet<(String, String)>) {
let mut out = String::from(
"# Value-parity baseline: (file, tag) whose printed value differs from ExifTool.\n\
# New deltas fail regression_tag_values; fixes tighten it.\n\
# Regenerate: UPDATE_VALUE_BASELINE=1 cargo test --release --test regression regression_tag_values\n",
);
for (file, tag) in deltas {
out.push_str(file);
out.push('\t');
out.push_str(tag);
out.push('\n');
}
std::fs::write(VALUE_BASELINE, out).unwrap();
}
#[cfg(unix)]
#[test]
fn regression_tag_values() {
let (current, tested) = current_value_deltas();
assert!(tested >= 100, "Expected at least 100 files, got {tested}");
if std::env::var_os("UPDATE_VALUE_BASELINE").is_some() {
write_value_baseline(¤t);
eprintln!(
"Wrote {VALUE_BASELINE}: {} value delta(s) over {tested} files.",
current.len()
);
return;
}
if cfg!(debug_assertions) {
eprintln!("debug build: value parity not enforced. Run `cargo test --release`.");
return;
}
let baseline = read_value_baseline();
let regressions: Vec<_> = current.difference(&baseline).collect();
let improvements = baseline.difference(¤t).count();
if improvements > 0 {
eprintln!(
"✨ {improvements} value delta(s) fixed — tighten with \
`UPDATE_VALUE_BASELINE=1 cargo test --release --test regression regression_tag_values`."
);
}
assert!(
regressions.is_empty(),
"{} NEW tag-value delta(s) vs ExifTool:\n{}\n\nIf intentional, regenerate with \
UPDATE_VALUE_BASELINE=1.",
regressions.len(),
regressions
.iter()
.map(|(f, t)| format!(" {f}\t{t}"))
.collect::<Vec<_>>()
.join("\n"),
);
}
#[test]
fn all_test_files_parse_without_panic() {
let images_dir = Path::new("tests/images");
let mut ok = 0;
let mut err = 0;
let mut panicked = 0;
let mut panic_files = Vec::new();
for entry in std::fs::read_dir(images_dir).unwrap() {
let entry = entry.unwrap();
let path = entry.path();
let file_name = entry.file_name().to_string_lossy().to_string();
match safe_extract(&path) {
Some(_) => ok += 1,
None => {
let et = ExifTool::new();
let is_panic = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let _ = et.extract_info(&path);
}))
.is_err();
if is_panic {
panicked += 1;
panic_files.push(file_name);
} else {
err += 1;
}
}
}
}
println!(
"Parsed: {} ok, {} errors, {} panics out of {}",
ok,
err,
panicked,
ok + err + panicked
);
if !panic_files.is_empty() {
println!("Files that caused panics:");
for f in &panic_files {
println!(" {}", f);
}
}
assert!(
ok >= 150,
"Expected at least 150 successful parses, got {}",
ok
);
}