use anyhow::{Context, Result};
use std::io::Write;
use std::path::Path;
use std::sync::Mutex;
const MAX_SAMPLES: usize = 10;
#[derive(Default)]
pub struct Log(Option<Mutex<std::fs::File>>);
impl Log {
pub fn open(path: Option<&Path>) -> Result<Log> {
let Some(path) = path else { return Ok(Log(None)) };
let mut f = std::fs::File::create(path)
.with_context(|| format!("could not create the log file {}", path.display()))?;
let argv: Vec<String> = std::env::args().collect();
let _ = writeln!(f, "# img-fp {}\n# {}", env!("CARGO_PKG_VERSION"), argv.join(" "));
Ok(Log(Some(Mutex::new(f))))
}
pub fn active(&self) -> bool {
self.0.is_some()
}
pub fn line(&self, s: &str) {
if let Some(f) = &self.0 {
let mut f = f.lock().unwrap_or_else(|e| e.into_inner());
let _ = writeln!(f, "{s}");
}
}
}
#[derive(Default)]
struct Tally {
count: usize,
samples: Vec<String>,
}
impl Tally {
fn record(&mut self, what: String) {
self.count += 1;
if self.samples.len() < MAX_SAMPLES {
self.samples.push(what);
}
}
}
fn record(log: &Log, tally: &mut Tally, label: &str, what: String) {
if log.active() {
log.line(&format!("{label}: {what}"));
}
tally.record(what);
}
pub struct Problems<'a> {
log: &'a Log,
not_an_image: Tally,
not_image_content: Tally,
symlink: Tally,
symlink_loop: Tally,
listed_twice: Tally,
excluded: Tally,
unresolved_exclude: Tally,
unscannable: Tally,
unreadable: Tally,
featureless: Tally,
cache: Tally,
}
impl<'a> Problems<'a> {
pub fn new(log: &'a Log) -> Self {
Problems {
log,
not_an_image: Tally::default(),
not_image_content: Tally::default(),
symlink: Tally::default(),
symlink_loop: Tally::default(),
listed_twice: Tally::default(),
excluded: Tally::default(),
unresolved_exclude: Tally::default(),
unscannable: Tally::default(),
unreadable: Tally::default(),
featureless: Tally::default(),
cache: Tally::default(),
}
}
pub fn not_an_image(&mut self, path: &str) {
record(self.log, &mut self.not_an_image, "skip/not-an-image", path.into());
}
pub fn symlink(&mut self, path: &str) {
record(self.log, &mut self.symlink, "skip/symlink", path.into());
}
pub fn symlink_loop(&mut self, path: &str) {
record(self.log, &mut self.symlink_loop, "skip/symlink-loop", path.into());
}
pub fn excluded(&mut self, path: &str) {
record(self.log, &mut self.excluded, "skip/excluded", path.into());
}
pub fn not_image_content(&mut self, path: &str) {
record(self.log, &mut self.not_image_content, "skip/not-image-content", path.into());
}
pub fn listed_twice(&mut self, path: &str) {
record(self.log, &mut self.listed_twice, "skip/listed-twice", path.into());
}
pub fn unresolved_exclude(&mut self, path: &str, err: &dyn std::fmt::Display) {
record(self.log, &mut self.unresolved_exclude, "problem/unresolved-exclude", format!("{path}: {err}"));
}
pub fn unscannable(&mut self, path: &str, err: &dyn std::fmt::Display) {
record(self.log, &mut self.unscannable, "problem/unscannable", format!("{path}: {err}"));
}
pub fn unreadable(&mut self, path: &str, err: &str) {
record(self.log, &mut self.unreadable, "problem/unreadable", format!("{path}: {err}"));
}
pub fn featureless(&mut self, path: &str) {
record(self.log, &mut self.featureless, "problem/featureless", path.into());
}
pub fn cache(&mut self, what: String) {
record(self.log, &mut self.cache, "problem/cache", what);
}
fn skips(&self) -> [(&Tally, &'static str); 6] {
[
(&self.not_an_image, "file(s) whose extension is not searched (see -x)"),
(&self.not_image_content, "file(s) that are not images (reached by a wildcard -x)"),
(&self.symlink, "symlink(s), which are not followed (see --follow-symlinks)"),
(&self.symlink_loop, "symlink(s) leading back into a folder already being walked"),
(&self.listed_twice, "path(s) already listed under another name (named twice, overlapping roots, a symlink or a hard link)"),
(&self.excluded, "named path(s) skipped because --exclude covers them"),
]
}
fn problems(&self) -> [(&Tally, &'static str); 5] {
[
(&self.unresolved_exclude, "--exclude path(s) could not be resolved; nothing was excluded for them"),
(&self.unscannable, "path(s) could not be scanned"),
(&self.unreadable, "image(s) could not be read"),
(&self.featureless, "image(s) have no features and can only match a byte-identical copy"),
(&self.cache, "cache problem(s)"),
]
}
pub fn any(&self) -> bool {
self.count() > 0
}
pub fn walk_was_complete(&self) -> bool {
self.unscannable.count == 0
}
pub fn count(&self) -> usize {
self.problems().iter().map(|(t, _)| t.count).sum()
}
pub fn print_summary(&self) {
let skips: Vec<_> = self.skips().into_iter().filter(|(t, _)| t.count > 0).collect();
if !skips.is_empty() {
self.say("\nSkipped:");
for (tally, label) in skips {
self.render(tally, label);
}
}
let problems: Vec<_> = self.problems().into_iter().filter(|(t, _)| t.count > 0).collect();
if !problems.is_empty() {
self.say(&format!("\nProblems ({} total):", self.count()));
for (tally, label) in problems {
self.render(tally, label);
}
}
}
fn render(&self, tally: &Tally, label: &str) {
self.say(&format!(" {:>5} {}", tally.count, label));
for s in tally.samples.iter() {
self.say(&format!(" - {s}"));
}
let hidden = tally.count - tally.samples.len();
if hidden > 0 {
let more = match self.log.active() {
true => format!(" - ... and {hidden} more (all of them are in the log file)"),
false => format!(" - ... and {hidden} more (--log-file lists them all)"),
};
self.say(&more);
}
}
fn say(&self, line: &str) {
eprintln!("{line}");
self.log.line(line);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn p(log: &Log) -> Problems<'_> {
Problems::new(log)
}
#[test]
fn samples_are_capped_but_the_count_is_not() {
let log = Log::default();
let mut p = p(&log);
for i in 0..50 {
p.unreadable(&format!("/x/{i}.jpg"), "not an image");
}
assert_eq!(p.count(), 50, "every failure is counted");
assert_eq!(p.unreadable.samples.len(), MAX_SAMPLES, "only a few are named");
assert!(p.any());
}
#[test]
fn a_clean_run_has_nothing_to_report() {
let log = Log::default();
let p = p(&log);
assert!(!p.any(), "and so exits 0");
assert_eq!(p.count(), 0);
}
#[test]
fn a_skip_is_not_a_problem_and_does_not_move_the_exit_code() {
let log = Log::default();
let mut p = p(&log);
p.not_an_image("/notes.txt");
p.not_image_content("/README");
p.symlink("/link.jpg");
p.symlink_loop("/a/up");
p.listed_twice("/a.jpg");
p.excluded("/keep");
assert_eq!(p.count(), 0, "skips are not failures");
assert!(!p.any(), "so a scan of a home directory still exits 0");
}
#[test]
fn every_problem_category_counts_towards_the_exit_code() {
let log = Log::default();
let mut p = p(&log);
p.unscannable("/nope", &"No such file or directory");
p.unreadable("/a.jpg", "unsupported");
p.featureless("/blank.png");
p.cache("could not write /tmp/c".into());
p.unresolved_exclude("/kepe", &"No such file or directory");
assert_eq!(p.count(), 5);
}
#[test]
fn the_log_file_holds_every_line_the_console_elides() {
let dir = std::env::temp_dir().join(format!("img-fp-log-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("run.log");
{
let log = Log::open(Some(&path)).unwrap();
let mut p = p(&log);
for i in 0..50 {
p.unreadable(&format!("/x/{i}.jpg"), "not an image");
}
p.print_summary();
}
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("# img-fp"), "the log names the run that wrote it");
for i in 0..50 {
assert!(text.contains(&format!("/x/{i}.jpg")), "every failure, not the first ten");
}
assert!(text.contains("Problems (50 total):"), "and the conclusion drawn from them");
std::fs::remove_dir_all(&dir).ok();
}
}