camshooter 0.1.2

Select a webcam, preview the live stream, and grab PNG snapshots with a keypress.
//! Snapshot saving: build a timestamped filename and write the current frame to PNG.
//!
//! Naming spec: `{prefix}{ddMMyyHHmmss}.png`, 24-hour clock — e.g.
//! `snapshooter060626143012.png`. Files go to the configured output directory, which is
//! created if missing.

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;

/// Upper bound on same-second collision suffixes before we give up (defends against a
/// pathological directory rather than a realistic case).
const MAX_COLLISION_SUFFIX: u32 = 10_000;

/// What to do when the target filename already exists (a same-second collision, since
/// the timestamp only has 1-second resolution).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OnCollision {
    /// Append `-1`, `-2`, … so an existing capture is never lost. The default.
    #[default]
    Suffix,
    /// Overwrite the existing file in place.
    Overwrite,
}

/// Everything needed to name and place a snapshot. Built from the config + CLI flags.
#[derive(Debug, Clone)]
pub struct SnapshotSettings {
    /// Directory to write into (created on first save if missing).
    pub dir: PathBuf,
    /// Filename prefix (before the timestamp).
    pub prefix: String,
    /// Collision policy.
    pub on_collision: OnCollision,
}

/// Build the filename *stem* (no directory, no `.png`, no collision suffix) for an
/// instant. Format: `{prefix}{ddMMyyHHmmss}` on a 24-hour clock.
fn timestamp_stem(prefix: &str, now: DateTime<Local>) -> String {
    // chrono format codes: %d=day %m=month %y=2-digit year %H=24h hour %M=min %S=sec
    format!("{prefix}{}", now.format("%d%m%y%H%M%S"))
}

/// Reserve and return the path to write to, honoring the collision policy.
///
/// `Overwrite` returns `{stem}.png` (the later `save` truncates it). `Suffix` atomically
/// creates the first free name — `{stem}.png`, else `{stem}-1.png`, `-2`, … — using
/// `create_new`, so two snapshots taken in the same second can never resolve to the same
/// file (no TOCTOU). The empty file it creates is overwritten by the PNG write that
/// follows. Returns an error if no free name is found within `MAX_COLLISION_SUFFIX`.
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), // we now exclusively own this name
            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()
    )
}

/// Encode `frame` as PNG using `settings`, returning the path actually written.
///
/// Creates the output directory (and parents) if missing. The timestamp is taken from
/// the local clock at call time. Errors carry context (which path failed and why) so the
/// UI can show an actionable message in its status line.
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() {
        // 2026-06-06 14:30:12 local -> snapshooter 06 06 26 14 30 12
        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); // tiny black image, no hardware needed
        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}"
        );
        // Re-open it to prove it's a real PNG with the right dimensions.
        let reopened = image::open(&path).unwrap().to_rgb8();
        assert_eq!(reopened.dimensions(), (4, 3));
    }

    #[test]
    fn suffix_policy_never_overwrites() {
        // Two reservations in the same second must NOT collide: second gets `-1`.
        // `reserve_snapshot_path` atomically creates each name, so no manual setup needed.
        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");
    }
}