use std::path::Path;
use std::process::Command;
use exiftool_rs::ExifTool;
struct Case {
id: &'static str,
image: &'static str,
ops: &'static [(&'static str, Option<&'static str>)],
}
const CASES: &[Case] = &[
Case {
id: "set_byline_accent",
image: "IPTC.jpg",
ops: &[("IPTC:By-line", Some("Martín"))],
},
Case {
id: "set_headline",
image: "IPTC.jpg",
ops: &[("IPTC:Headline", Some("New headline"))],
},
Case {
id: "delete_byline",
image: "IPTC.jpg",
ops: &[("IPTC:By-line", None)],
},
Case {
id: "add_city",
image: "IPTC.jpg",
ops: &[("IPTC:City", Some("Paris"))],
},
Case {
id: "multi_set_add_delete",
image: "IPTC.jpg",
ops: &[
("IPTC:By-line", Some("Ada Lovelace")),
("IPTC:City", Some("London")),
("IPTC:Headline", None),
],
},
];
fn snapshot(path: &Path) -> String {
let et = ExifTool::new();
let tags = et.extract_info(path).expect("read written file");
let digest = tags
.iter()
.find(|t| t.name == "CurrentIPTCDigest")
.map(|t| t.print_value.as_str())
.unwrap_or("<none>");
let mut out = format!("DIGEST {digest}\n");
for t in tags.iter().filter(|t| t.group.family0 == "IPTC") {
out.push_str(&format!("{}: {}\n", t.name, t.print_value));
}
out
}
fn write_ours(src: &Path, dst: &Path, ops: &[(&str, Option<&str>)]) {
std::fs::copy(src, dst).expect("copy source");
let mut et = ExifTool::new();
for (tag, val) in ops {
et.set_new_value(tag, *val);
}
let p = dst.to_str().unwrap();
et.write_info(p, p).expect("exiftool-rs write");
}
fn write_perl(src: &Path, dst: &Path, ops: &[(&str, Option<&str>)]) {
std::fs::copy(src, dst).expect("copy source");
let mut cmd = Command::new("perl");
cmd.arg("../exiftool/exiftool")
.arg("-charset")
.arg("UTF8")
.arg("-overwrite_original");
for (tag, val) in ops {
match val {
Some(v) => cmd.arg(format!("-{tag}={v}")),
None => cmd.arg(format!("-{tag}=")),
};
}
let out = cmd.arg(dst).output().expect("run perl exiftool");
assert!(
out.status.success(),
"perl exiftool failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn iptc_write_parity() {
let regen = std::env::var("WRITE_PARITY_REGEN").is_ok();
let img_dir = Path::new("tests/images");
let exp_dir = Path::new("tests/expected_write");
if !img_dir.join("IPTC.jpg").exists() {
eprintln!("tests/images/IPTC.jpg missing — skipping write-parity");
return;
}
let mut mismatches = Vec::new();
for case in CASES {
let src = img_dir.join(case.image);
let tmp = std::env::temp_dir().join(format!("exiftool_rs_wp_{}.jpg", case.id));
let exp = exp_dir.join(format!("{}.snap", case.id));
if regen {
write_perl(&src, &tmp, case.ops);
let snap = snapshot(&tmp);
std::fs::create_dir_all(exp_dir).unwrap();
std::fs::write(&exp, &snap).unwrap();
eprintln!("regenerated {}", exp.display());
} else {
write_ours(&src, &tmp, case.ops);
let got = snapshot(&tmp).replace("\r\n", "\n");
let want = std::fs::read_to_string(&exp)
.unwrap_or_else(|_| {
panic!(
"missing baseline {} — run `WRITE_PARITY_REGEN=1 cargo test --test write_parity`",
exp.display()
)
})
.replace("\r\n", "\n");
if got != want {
mismatches.push(format!(
"case `{}`:\n--- expected (Perl ExifTool) ---\n{want}--- got (exiftool-rs) ---\n{got}",
case.id
));
}
}
std::fs::remove_file(&tmp).ok();
}
assert!(
mismatches.is_empty(),
"write-parity mismatch vs Perl ExifTool:\n\n{}",
mismatches.join("\n")
);
}