camshooter 0.1.2

Select a webcam, preview the live stream, and grab PNG snapshots with a keypress.
//! Optional TOML configuration.
//!
//! Read from `$XDG_CONFIG_HOME/camshooter/config.toml`, falling back to
//! `$HOME/.config/camshooter/config.toml`. The file is optional — when it is
//! missing every value falls back to a built-in default. All keys are optional:
//!
//! ```toml
//! output_dir = "~/Pictures/cam"   # default save dir; overridden by the -o CLI flag
//! prefix = "snapshooter"           # snapshot filename prefix
//! on_collision = "suffix"          # "suffix" | "overwrite"
//! ```

use std::path::PathBuf;

use serde::Deserialize;

use crate::snapshot::OnCollision;

/// Parsed config file. `#[serde(default)]` makes every field optional; an absent file
/// yields `Config::default()`. `deny_unknown_fields` turns typo'd keys into a clear
/// parse error instead of being silently ignored.
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
    /// Default save directory (a leading `~/` is expanded). Overridden by `-o`.
    pub output_dir: Option<String>,
    /// Snapshot filename prefix.
    pub prefix: String,
    /// What to do when a filename already exists.
    pub on_collision: OnCollision,
}

impl Default for Config {
    fn default() -> Self {
        Self {
            output_dir: None,
            prefix: "snapshooter".into(),
            on_collision: OnCollision::Suffix,
        }
    }
}

impl Config {
    /// The configured output directory as a path with `~/` expanded, if set.
    pub fn output_dir_path(&self) -> Option<PathBuf> {
        self.output_dir.as_deref().map(expand_tilde)
    }
}

/// Where the config file lives: `$XDG_CONFIG_HOME` (if absolute, per the XDG spec) else
/// `$HOME/.config`. Returns `None` only when neither env var is usable.
fn config_path() -> Option<PathBuf> {
    let base = std::env::var_os("XDG_CONFIG_HOME")
        .map(PathBuf::from)
        .filter(|p| p.is_absolute())
        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
    Some(base.join("camshooter").join("config.toml"))
}

/// Expand a leading `~/` (or a bare `~`) to `$HOME`. Anything else passes through
/// unchanged.
fn expand_tilde(p: &str) -> PathBuf {
    if let Some(home) = std::env::var_os("HOME") {
        if p == "~" {
            return PathBuf::from(home);
        }
        if let Some(rest) = p.strip_prefix("~/") {
            return PathBuf::from(home).join(rest);
        }
    }
    PathBuf::from(p)
}

/// Load the config. A missing file yields defaults; a malformed file (or one with an
/// unsafe `prefix`) yields a clear error string for `main` to print before exiting.
pub fn load() -> Result<Config, String> {
    let cfg = read_config()?;
    // A prefix is joined onto the output dir as a filename component. Reject path
    // separators so a config file can't write outside the output directory.
    if cfg.prefix.contains('/') || cfg.prefix.contains('\\') {
        return Err(format!(
            "config `prefix` {:?} must not contain path separators",
            cfg.prefix
        ));
    }
    Ok(cfg)
}

fn read_config() -> Result<Config, String> {
    let Some(path) = config_path() else {
        return Ok(Config::default());
    };
    let text = match std::fs::read_to_string(&path) {
        Ok(t) => t,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Config::default()),
        Err(e) => return Err(format!("failed to read {}: {e}", path.display())),
    };
    toml::from_str::<Config>(&text)
        .map_err(|e| format!("invalid config at {}:\n{e}", path.display()))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn empty_file_is_all_defaults() {
        let cfg: Config = toml::from_str("").unwrap();
        assert_eq!(cfg.prefix, "snapshooter");
        assert_eq!(cfg.on_collision, OnCollision::Suffix);
        assert!(cfg.output_dir.is_none());
    }

    #[test]
    fn parses_a_full_config() {
        let cfg: Config = toml::from_str(
            r#"
            output_dir = "/tmp/shots"
            prefix = "cam"
            on_collision = "overwrite"
        "#,
        )
        .unwrap();
        assert_eq!(cfg.output_dir.as_deref(), Some("/tmp/shots"));
        assert_eq!(cfg.prefix, "cam");
        assert_eq!(cfg.on_collision, OnCollision::Overwrite);
    }

    #[test]
    fn unknown_key_is_rejected() {
        let err = toml::from_str::<Config>("nope = 1");
        assert!(err.is_err(), "unknown keys should be rejected");
    }
}