use std::fs::OpenOptions;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use chrono::{DateTime, Local};
use image::RgbImage;
use serde::Deserialize;
const MAX_COLLISION_SUFFIX: u32 = 10_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OnCollision {
#[default]
Suffix,
Overwrite,
}
#[derive(Debug, Clone)]
pub struct SnapshotSettings {
pub dir: PathBuf,
pub prefix: String,
pub on_collision: OnCollision,
}
fn timestamp_stem(prefix: &str, now: DateTime<Local>) -> String {
format!("{prefix}{}", now.format("%d%m%y%H%M%S"))
}
fn reserve_snapshot_path(dir: &Path, stem: &str, policy: OnCollision) -> Result<PathBuf> {
if policy == OnCollision::Overwrite {
return Ok(dir.join(format!("{stem}.png")));
}
for n in std::iter::once(None).chain((1..=MAX_COLLISION_SUFFIX).map(Some)) {
let path = match n {
None => dir.join(format!("{stem}.png")),
Some(n) => dir.join(format!("{stem}-{n}.png")),
};
match OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(_) => return Ok(path), Err(e) if e.kind() == ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e).with_context(|| format!("reserving {}", path.display())),
}
}
bail!(
"too many snapshots with the same timestamp in {}",
dir.display()
)
}
pub fn save_snapshot(frame: &RgbImage, settings: &SnapshotSettings) -> Result<PathBuf> {
std::fs::create_dir_all(&settings.dir)
.with_context(|| format!("creating output directory {}", settings.dir.display()))?;
let stem = timestamp_stem(&settings.prefix, Local::now());
let path = reserve_snapshot_path(&settings.dir, &stem, settings.on_collision)?;
frame
.save(&path)
.with_context(|| format!("writing snapshot to {}", path.display()))?;
Ok(path)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn settings(dir: &Path, on_collision: OnCollision) -> SnapshotSettings {
SnapshotSettings {
dir: dir.to_path_buf(),
prefix: "snapshooter".into(),
on_collision,
}
}
#[test]
fn timestamp_stem_matches_ddmmyyhhmmss() {
let when = Local.with_ymd_and_hms(2026, 6, 6, 14, 30, 12).unwrap();
assert_eq!(
timestamp_stem("snapshooter", when),
"snapshooter060626143012"
);
}
#[test]
fn timestamp_stem_honors_custom_prefix() {
let when = Local.with_ymd_and_hms(2026, 6, 6, 14, 30, 12).unwrap();
assert_eq!(timestamp_stem("cam", when), "cam060626143012");
}
#[test]
fn save_snapshot_writes_a_valid_png() {
let dir = tempfile::tempdir().unwrap();
let frame = RgbImage::new(4, 3); let path = save_snapshot(&frame, &settings(dir.path(), OnCollision::Suffix)).unwrap();
assert!(path.exists(), "file should exist");
let name = path.file_name().unwrap().to_str().unwrap();
assert!(
name.starts_with("snapshooter") && name.ends_with(".png"),
"unexpected name: {name}"
);
let reopened = image::open(&path).unwrap().to_rgb8();
assert_eq!(reopened.dimensions(), (4, 3));
}
#[test]
fn suffix_policy_never_overwrites() {
let dir = tempfile::tempdir().unwrap();
let first =
reserve_snapshot_path(dir.path(), "snapshooter060626143012", OnCollision::Suffix)
.unwrap();
assert_eq!(first.file_name().unwrap(), "snapshooter060626143012.png");
let second =
reserve_snapshot_path(dir.path(), "snapshooter060626143012", OnCollision::Suffix)
.unwrap();
assert_eq!(second.file_name().unwrap(), "snapshooter060626143012-1.png");
}
#[test]
fn overwrite_policy_reuses_the_same_name() {
let dir = tempfile::tempdir().unwrap();
let first = reserve_snapshot_path(
dir.path(),
"snapshooter060626143012",
OnCollision::Overwrite,
)
.unwrap();
let second = reserve_snapshot_path(
dir.path(),
"snapshooter060626143012",
OnCollision::Overwrite,
)
.unwrap();
assert_eq!(first, second, "overwrite should reuse the same path");
}
}