summary history branches tags files
src/main.rs
// # License (AGPL Version 3.0)
//
// photo-what-what - tool to automate tagging of image files
// Copyright (C) 2025 Trevor Bentley <pww@x.mrmekon.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.
//
use regex::Regex;
use std::collections::HashSet;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use image2::Image;
use tempfile::NamedTempFile;
use clap::{Parser, ValueEnum};
use serde::{Serialize, Deserialize};

#[derive(Debug)]
enum PwwError {
    Unknown(String),
    MissingSidecar(String),
    InvalidImage,
}
impl std::fmt::Display for PwwError {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        match self {
            PwwError::Unknown(s) => write!(f, "PWW error: {}", s),
            PwwError::MissingSidecar(s) => write!(f, "missing XMP sidecar: {}", s),
            PwwError::InvalidImage => write!(f, "input image invalid"),
        }
    }
}

#[derive(Parser, Debug)]
#[clap(trailing_var_arg=true, term_width=100)]
struct PwwArgs {
    /// Path to program that classifies images
    #[arg(short = 'b', long)]
    identifier_bin: Option<PathBuf>,

    /// Directory to store temporary files
    #[arg(long)]
    temp_dir: Option<PathBuf>,

    /// Do not actually write metadata to file, but print which files
    /// would have changed.
    #[arg(short = 'n', long)]
    dry_run: bool,

    /// Skip files that have already been processed by pww
    #[arg(short = 's', long)]
    skip_processed: bool,

    /// Policy for which metadata should be updated
    #[arg(long)]
    file_update_policy: Option<FileUpdatePolicy>,

    /// Policy for how changes should be made to the specified tags.
    #[arg(long)]
    tag_update_policy: Option<TagUpdatePolicy>,

    /// Print information about which tags are written
    #[arg(short = 'v', long)]
    verbose: bool,

    /// Print extra trace information about program flow, for debugging.
    #[arg(short = 'd', long)]
    debug: bool,

    /// Whether to stop processing after first error, or continue.
    #[arg(short = 'e', long)]
    halt_on_error: bool,

    /// Keep converted image files in temp directory instead of
    /// removing them.
    #[arg(long)]
    keep_converted: bool,

    /// Disables setting of the pww processed metadata field.
    #[arg(long)]
    disable_pww_tag: bool,

    /// Image files to analyze and tag
    #[arg(trailing_var_arg = true, allow_hyphen_values = true, value_name = "IMAGE", required = true)]
    image_paths: Vec<PathBuf>,
}

#[derive(Default, Debug, Clone, Serialize, Deserialize, ValueEnum)]
enum TagUpdatePolicy {
    /// Replace all existing contents (if any) with new items
    Replace,
    /// Append new items to existing tag contents
    Append,
    /// Remove existing items with the same prefix and then append new ones.
    #[default]
    ReplacePrefixed,
}

#[derive(Default, Debug, Clone, Serialize, Deserialize, ValueEnum, PartialEq)]
enum FileUpdatePolicy {
    /// Just print the tags, do not update any files
    DisplayOnly,

    /// Only update XMP sidecar (error if XMP file missing).  Only
    /// sidecar tags are used.
    #[default]
    SidecarOnly,

    /// Only update image itself.  Only image tags are used.
    ImageOnly,

    /// Update both XMP sidecar and image itself.  Both sidecar and
    /// image tags are used.
    SidecarAndImage,

    /// Only update XMP sidecar if present, otherwise update image
    /// itself.  Either sidecar or image tags are used.
    SidecarIfPresent,

    /// Only update XMP sidecar unless image is one of a few common
    /// known types with good EXIF support (JPG, PNG, TIFF, WebP).
    /// Either sidecar or image tags are used.  Error if XMP file
    /// missing.
    SidecarUnlessCommonImage,
}

#[derive(Default, Clone, Serialize, Deserialize, ValueEnum)]
enum DownscalePolicy {
    /// Images that require conversion due to unsupported input format
    /// or images that exceed the maximum dimension are downscaled.
    #[default]
    LargeOrConverted,

    /// Only images that require conversion due to unsupported input
    /// format are downscaled.
    ConvertedOnly,

    /// Images that exceed the maximum dimensions are downscaled.
    Large,

    /// Images are never downscaled.  They are processed at the original size.
    Never,

    /// Images are always downscaled.  This means that all images
    /// larger than the minimum dimension will be converted.
    Always,
}

#[derive(Clone, Debug, Serialize, Deserialize, ValueEnum, PartialEq)]
enum ValidImageFormat {
    Png,
    Jpg,
    Tiff,
    Unknown,
}

impl From<image::ImageFormat> for ValidImageFormat {
    fn from(fmt: image::ImageFormat) -> Self {
        match fmt {
            image::ImageFormat::Jpeg => ValidImageFormat::Jpg,
            image::ImageFormat::Png => ValidImageFormat::Png,
            image::ImageFormat::Tiff => ValidImageFormat::Tiff,
            _ => ValidImageFormat::Unknown,
        }
    }
}

#[derive(Clone, Debug, Serialize, Deserialize, ValueEnum, PartialEq)]
enum ValidImageColor {
    Rgb8,
    Rgba8,
    Rgb16,
    Rgba16,
    Unknown,
}

impl From<image::ColorType> for ValidImageColor {
    fn from(fmt: image::ColorType) -> Self {
        match fmt {
            image::ColorType::Rgb8 => ValidImageColor::Rgb8,
            image::ColorType::Rgba8 => ValidImageColor::Rgba8,
            image::ColorType::Rgb16 => ValidImageColor::Rgb16,
            image::ColorType::Rgba16 => ValidImageColor::Rgba16,
            _ => ValidImageColor::Unknown,
        }
    }
}

#[derive(Serialize, Deserialize)]
struct PwwConfig {
    /// Path to program that classifies images
    identifier_bin: Option<PathBuf>,

    /// Extra arguments passed to identifier binary before the image
    /// filename.
    identifier_bin_args: Vec<String>,

    /// Directory to store temporary files
    temp_dir: Option<PathBuf>,

    /// Skip files that have already been processed by pww
    skip_processed: bool,

    /// Policy for which metadata should be updated
    file_update_policy: FileUpdatePolicy,

    /// Policy for how changes should be made to the specified tags.
    tag_update_policy: TagUpdatePolicy,

    /// Prefix that should be appended to all tags
    tag_prefix: Option<String>,

    /// Full hierarchical XMP, EXIF, or IPTC tags to write to sidecar files.
    ///
    /// examples:
    ///  - Xmp.dc.subject
    ///  - Xmp.lr.hierarchicalSubject
    ///  - Xmp.digiKam.TagsList
    sidecar_tags: Vec<String>,

    /// Full hierarchical XMP, EXIF, or IPTC tags to write to image files.
    ///
    /// examples:
    ///  - Iptc.Application2.Keywords
    ///  - Xmp.dc.subject
    ///  - Xmp.lr.hierarchicalSubject
    ///  - Xmp.digiKam.TagsList
    image_tags: Vec<String>,

    /// Maximum input image dimension (in pixels).  If height or width
    /// exceeds this, image will be downscaled before analysis.
    max_dimension: u32,

    /// Minimum image dimension when converting or downscaling.
    /// Neither height nor width will shrink below this dimension (in
    /// pixels).
    min_dimension: u32,

    /// Policy for when images should be downscaled
    downscale_policy: DownscalePolicy,

    /// Image formats that can be analyzed without conversion
    valid_image_formats: Vec<ValidImageFormat>,

    /// Image color formats that can be analyzed without conversion.
    valid_image_colors: Vec<ValidImageColor>,

    /// Whether to stop processing after first error, or continue.
    halt_on_error: bool,

    /// List of tags that are allowed to be added to image metadata.
    /// Any other tags in output will be ignored.  If not provided,
    /// all tags permitted.
    permitted_tags: Vec<String>,

    /// List of tags that are forbidden from being added to image
    /// metadata.
    forbidden_tags: Vec<String>,

    /// Disables setting of the pww processed metadata field.
    ///
    /// pww sets an Xmp tag indicating it has processed a file.  This
    /// can be used to skip reprocessing of files that have already
    /// been handled.
    disable_pww_tag: bool,

    #[serde(skip_serializing, skip_deserializing)]
    cli: Option<PwwArgs>,
}

impl PwwConfig {
    fn identifier_bin(&self) -> Option<PathBuf> {
        match self.cli.as_ref().map(|c| c.identifier_bin.clone()) {
            Some(ref b) => b.clone(),
            _ => self.identifier_bin.clone()
        }
    }

    fn temp_dir(&self) -> Option<PathBuf> {
        match &self.cli {
            Some(c) => c.temp_dir.clone(),
            _ => self.temp_dir.clone(),
        }
    }

    fn max_dimension(&self) -> u32 {
        match self.max_dimension {
            0 => std::u32::MAX,
            _ => self.max_dimension
        }
    }

    fn min_dimension(&self) -> u32 {
        match self.max_dimension {
            0 => std::u32::MAX,
            _ => self.max_dimension
        }
    }

    fn image_paths(&self) -> Vec<PathBuf> {
        match &self.cli {
            Some(c) => c.image_paths.clone(),
            _ => Default::default(),
        }
    }

    fn dry_run(&self) -> bool {
        self.cli.as_ref().map(|c| c.dry_run).unwrap_or_default()
    }

    fn skip_processed(&self) -> bool {
        self.cli.as_ref().map(|c| c.skip_processed).unwrap_or(self.skip_processed)
    }

    fn tag_update_policy(&self) -> TagUpdatePolicy {
        match &self.cli {
            Some(c) => match &c.tag_update_policy {
                Some(p) => p.clone(),
                _ => self.tag_update_policy.clone(),
            },
            _ => self.tag_update_policy.clone(),
        }
    }

    fn file_update_policy(&self) -> FileUpdatePolicy {
        match &self.cli {
            Some(c) => match &c.file_update_policy {
                Some(p) => p.clone(),
                _ => self.file_update_policy.clone(),
            },
            _ => self.file_update_policy.clone(),
        }
    }

    fn verbose(&self) -> bool {
        self.cli.as_ref().map(|c| c.verbose).unwrap_or_default()
    }

    #[allow(dead_code)]
    fn debug(&self) -> bool {
        self.cli.as_ref().map(|c| c.debug).unwrap_or_default()
    }

    fn halt_on_error(&self) -> bool {
        self.cli.as_ref().map(|c| c.halt_on_error).unwrap_or(self.halt_on_error)
    }

    fn keep_converted(&self) -> bool {
        self.cli.as_ref().map(|c| c.keep_converted).unwrap_or_default()
    }

    fn disable_pww_tag(&self) -> bool {
        self.cli.as_ref().map(|c| c.disable_pww_tag).unwrap_or(self.disable_pww_tag)
    }
}

impl ::std::default::Default for PwwConfig {
    fn default() -> Self {
        Self {
            identifier_bin: None,
            identifier_bin_args: vec!(),
            temp_dir: None,
            skip_processed: false,
            file_update_policy: FileUpdatePolicy::SidecarOnly,
            tag_update_policy: TagUpdatePolicy::ReplacePrefixed,
            tag_prefix: Some("[ML] ".to_owned()),

            sidecar_tags: vec!(
                // note: capitalization is critical: "subject" makes
                // an unordered list read by standard tools, "Subject"
                // makes a single string read by nothing.
                "Xmp.dc.subject".into(),
                "Xmp.lr.hierarchicalSubject".into(),
            ),
            image_tags: vec!(
                "Iptc.Application2.Keywords".into(),
            ),

            max_dimension: 8000,
            min_dimension: 768,
            downscale_policy: DownscalePolicy::LargeOrConverted,
            valid_image_formats: vec!(ValidImageFormat::Jpg,
                                      ValidImageFormat::Png
            ),
            valid_image_colors: vec!(ValidImageColor::Rgb8,
                                     ValidImageColor::Rgba8,
                                     ValidImageColor::Rgb16,
                                     ValidImageColor::Rgba16
            ),
            halt_on_error: false,
            disable_pww_tag: false,
            permitted_tags: vec!(),
            forbidden_tags: vec!(),
            cli: None,
        }
    }
}

fn exec_identifier<P: AsRef<Path>>(config: &PwwConfig, file_path: P) -> Result<String, PwwError> {
    let os_path = file_path.as_ref().as_os_str();
    let mut args: Vec<std::ffi::OsString> = config.identifier_bin_args.iter().map(|x| x.into()).collect();
    args.push(os_path.to_os_string());
    let mut p = std::process::Command::new(&config.identifier_bin().expect("No identifier binary provided."))
        .args(args)
        .stdout(Stdio::piped())
        .spawn()
        .map_err(|e| PwwError::Unknown(format!("Failed to launch identifier binary: {}", e)))?;
    let res = p.wait()
        .map_err(|e| PwwError::Unknown(format!("Failed to wait for identifier binary: {}", e)))?;
    if !res.success() {
        return Err(PwwError::Unknown("identifier binary exited with failure.".into()));
    }
    let mut s: String = String::new();
    let mut stdout = p.stdout.ok_or_else(|| PwwError::Unknown(format!("Failed to get output from identifier binary")))?;
    stdout.read_to_string(&mut s).map_err(|x| PwwError::Unknown(x.to_string()))?;
    Ok(s)
}

fn normalized_tags(config: &PwwConfig, output: String) -> Vec<String> {
    let re_num: Regex = Regex::new(r"^(\s*\d+[.]\s+)")
        .expect("Couldn't compile regex.");
    let re_bul: Regex = Regex::new(r"^(\s*[*]\s+)")
        .expect("Couldn't compile regex.");
    let re_lst: Regex = Regex::new(r"^(\s*[-]\s+)")
        .expect("Couldn't compile regex.");

    // trim line spacing
    let lines: Vec<String> = output.lines().map(|l| {
        // remove quotes
        let l = l.chars().filter(|c| !['"', '\'', '`'].contains(c)).collect::<String>()
        // make lowercase
            .to_lowercase()
        // trim line spacing
            .trim().to_owned();
        // remove numbering
        let l = re_num.replace_all(&l, "").trim().to_owned();
        // remove star bullets
        let l = re_bul.replace_all(&l, "").trim().to_owned();
        // remove dash bullets
        let l = re_lst.replace_all(&l, "").trim().to_owned();
        // change underscores and dashes to spaces
        l.replace("_", " ")
            .replace("-", " ")
        // remove space after commas
            .replace(", ", ",")
            .chars()
         // remove brackets
            .filter(|c| !"[]<>".chars().any(|x| c == &x)).collect()
    }).collect();

    let set: HashSet<String> = lines.join(",")
        .split(",")
        .map(|l| l.to_owned())
        .filter(|l| l.len() > 0)
        .collect();
    let mut tags = set.iter()
        .filter(|t| config.permitted_tags.is_empty() || config.permitted_tags.contains(t))
        .filter(|t| !config.forbidden_tags.contains(t))
        .map(|l| {
            config.tag_prefix.as_ref().map(|p| p.to_owned()).unwrap_or_default() + &l
        })
        .collect::<Vec<String>>();
    tags.sort();
    tags
}

// Assume that scripts can handle 8- or 16-bit JPG or PNG files.  All
// others require conversion.  Resize policy may also trigger
// conversion.
fn image_needs_conversion<P: AsRef<Path>>(config: &PwwConfig, filepath: P) -> Result<bool, PwwError> {
    let img = image::ImageReader::open(filepath)
        .map_err(|e| PwwError::Unknown(e.to_string()))?;
    let valid_format = match img.format() {
        Some(fmt) => {
            let local_fmt: ValidImageFormat = fmt.into();
            local_fmt != ValidImageFormat::Unknown &&
                config.valid_image_formats.contains(&local_fmt)
        },
        _ => false,
    };

    let (valid_color, valid_dimension) = match valid_format {
        true => {
            let img = img.decode().map_err(|e| PwwError::Unknown(e.to_string()))?;
            let local_color: ValidImageColor = img.color().into();
            match local_color != ValidImageColor::Unknown &&
                config.valid_image_colors.contains(&local_color) {
                    true => {
                        let max_dimension = std::cmp::max(img.width(), img.height());
                        match config.downscale_policy {
                            DownscalePolicy::Never |
                            DownscalePolicy::ConvertedOnly => {
                                (true, true)
                            },
                            DownscalePolicy::Always |
                            DownscalePolicy::Large |
                            DownscalePolicy::LargeOrConverted => {
                                (true, max_dimension <= config.max_dimension())
                            },
                        }
                    },
                    _ => (false, false),
                }
        },
        _ => (false, false),
    };

    let conversion_required = !valid_format || !valid_color || !valid_dimension;
    if config.debug() && conversion_required {
        if !valid_format {
            println!(" - conversion required: {}",
                     match (valid_format, valid_color, valid_dimension) {
                         (false, _, _) => "invalid format",
                         (true, false, _) => "invalid color space",
                         (true, true, false) => "invalid dimensions",
                         _ => "not required",
                     })
        }
    }
    Ok(conversion_required)
}

fn xmp_file_path<P: AsRef<Path>>(filepath: P) -> Result<Option<PathBuf>, PwwError> {
    // Look for XMP files, which must have the same filename plus
    // either .xmp or .XMP extension.
    let xmp_path: PathBuf = filepath.as_ref().to_path_buf();
    let ext = xmp_path.extension().unwrap_or_default().to_string_lossy().to_string();
    let xmp_ext_lower: String = ext.clone() + ".xmp";
    let xmp_ext_upper: String = ext + ".XMP";
    let xmp_path_lower = xmp_path.with_extension(xmp_ext_lower);
    let xmp_path_upper = xmp_path.with_extension(xmp_ext_upper);
    let lower_exists = std::path::Path::exists(&xmp_path_lower);
    let upper_exists = std::path::Path::exists(&xmp_path_upper);
    let xmp_path: Option<PathBuf> = match (lower_exists, upper_exists) {
        (false, false) => None,
        (false, true) => Some(xmp_path_upper),
        _ => Some(xmp_path_lower),
    };
    Ok(xmp_path)
}

fn should_update_sidecar<P: AsRef<Path>>(config: &PwwConfig, filepath: P) -> Result<Option<PathBuf>, PwwError> {
    let xmp_path: Option<PathBuf> = xmp_file_path(&filepath).unwrap_or_default();

    // Return path to XMP file if it exists and we're in a mode that
    // updates it, None if we shouldn't update it, or error if it's
    // required but missing.
    match config.file_update_policy() {
        FileUpdatePolicy::SidecarOnly | FileUpdatePolicy::SidecarAndImage => {
            match xmp_path {
                Some(_) => Ok(xmp_path),
                _ => Err(PwwError::MissingSidecar("XMP sidecar file not found.".into())),
            }
        },
        FileUpdatePolicy::SidecarUnlessCommonImage => {
            let img = image::ImageReader::open(filepath)
                .map_err(|e| PwwError::Unknown(e.to_string()))?;
            match img.format() {
                Some(image::ImageFormat::Jpeg) |
                Some(image::ImageFormat::Png) |
                Some(image::ImageFormat::Tiff) |
                Some(image::ImageFormat::WebP) => {
                    Ok(None)
                },
                _ => {
                    match xmp_path {
                        Some(_) => Ok(xmp_path),
                        _ => Err(PwwError::MissingSidecar("XMP sidecar file not found.".into())),
                    }
                }
            }
        }
        FileUpdatePolicy::SidecarIfPresent => {
            Ok(xmp_path)
        },
        FileUpdatePolicy::DisplayOnly | FileUpdatePolicy::ImageOnly => {
            Ok(None)
        },
    }
}

fn should_update_image<P: AsRef<Path>>(config: &PwwConfig, filepath: P) -> Result<bool, PwwError> {
    match config.file_update_policy() {
        FileUpdatePolicy::ImageOnly | FileUpdatePolicy::SidecarAndImage => Ok(true),
        FileUpdatePolicy::SidecarIfPresent => {
            match xmp_file_path(&filepath) {
                Ok(Some(_)) => Ok(false),
                _ => Ok(true),
            }
        }
        FileUpdatePolicy::SidecarUnlessCommonImage => {
            let img = image::ImageReader::open(filepath)
                .map_err(|e| PwwError::Unknown(e.to_string()))?;
            match img.format() {
                Some(image::ImageFormat::Jpeg) |
                Some(image::ImageFormat::Png) |
                Some(image::ImageFormat::Tiff) |
                Some(image::ImageFormat::WebP) => {
                    Ok(true)
                },
                _ => Ok(false),
            }
        }
        _ => Ok(false),
    }
}

fn write_tags_to_file<P: AsRef<std::ffi::OsStr>>(config: &PwwConfig, metatags: &[String], tags: &[String], filepath: P) -> Result<(), PwwError> {
    let meta = rexiv2::Metadata::new_from_path(&filepath)
        .map_err(|e| PwwError::Unknown(format!("Unable to read metadata from image file: {}", e)))?;
    if config.debug() {
        println!(" - updating file: {}", filepath.as_ref().to_string_lossy());
    }
    for metatag in metatags {
        let new_values: Vec<String> = match config.tag_update_policy() {
            TagUpdatePolicy::Append => {
                meta.get_tag_multiple_strings(&metatag).unwrap_or_default().into_iter().chain(tags.iter().cloned()).collect()
            },
            TagUpdatePolicy::Replace => {
                tags.to_vec()
            },
            TagUpdatePolicy::ReplacePrefixed => {
                let new_tags: Vec<String> = meta.get_tag_multiple_strings(&metatag)
                    .unwrap_or_default()
                    .iter()
                    .map(|t| t.trim().to_string())
                    .filter(|t| t.len() > 0 && !t.starts_with(&config.tag_prefix.clone().unwrap_or_default()))
                    .collect();
                new_tags.into_iter().chain(tags.iter().cloned()).collect()
            },
        };
        let ref_values: Vec<&str> = new_values.iter().map(|s| s.as_ref()).collect();

        meta.clear_tag(&metatag);
        meta.set_tag_multiple_strings(&metatag, &ref_values)
            .map_err(|e| PwwError::Unknown(format!("Failed to update metadata tag in file: {}", e)))?;
        if config.debug() {
            println!(" - updated tag: {}", metatag);
        }
    }

    if !config.disable_pww_tag() {
        let _ = rexiv2::register_xmp_namespace("http://trevor.town/pww/1.0/", "photo-what-what");
        let pww_tag = "Xmp.photo-what-what.processed";
        meta.clear_tag(pww_tag);
        meta.set_tag_numeric(pww_tag, 1)
            .map_err(|_e| PwwError::Unknown("Couldn't set pww processed tag".into()))?;
        if config.verbose() {
            println!(" - set pww processed flag");
        }
    }

    match config.dry_run() {
        false => {
            meta.save_to_file(&filepath)
                .map_err(|e| PwwError::Unknown(format!("Failed to write metadata to image file: {}", e)))?;
            if config.verbose() {
                println!(" - saved: {}\n", filepath.as_ref().to_string_lossy());
            }
        },
        true => {
            println!("would update: {} (dry run)\n", filepath.as_ref().to_string_lossy());
        },
    }
    Ok(())
}

fn process_image(config: &PwwConfig, input_path: &Path) -> Result<(), PwwError> {
    // If a tempfile is generated, it must live longer than all path
    // references.  It is deleted automatically when dropped.
    let tmpfile: Option<NamedTempFile>;

    // Path of file to analyze: either the input path if not
    // converted, or the tempfile path if converted.
    let mut analyze_path: PathBuf = input_path.to_path_buf();

    if config.verbose() {
        println!("Analyzing: {}", analyze_path.to_string_lossy());
    }

    // Check sidecar file before analysis because it's a common error
    // and we might as well not waste time analyzing when the tags
    // won't be writeable.
    let metafile = match should_update_sidecar(&config, &input_path)? {
        Some(p) => p,
        None => input_path.to_owned(),
    };

    // Check the metadata to see if this file has already been
    // processed, and skip it if configured to do so.
    let _ = rexiv2::register_xmp_namespace("http://trevor.town/pww/1.0/", "photo-what-what");
    let meta = rexiv2::Metadata::new_from_path(&metafile)
        .map_err(|e| PwwError::Unknown(format!("Unable to read metadata from image file: {}", e)))?;
    let pww_tag = "Xmp.photo-what-what.processed";
    if config.skip_processed() && meta.get_tag_numeric(pww_tag) == 1 {
        if config.verbose() {
            println!(" - already processed, skipped!")
        }
        return Ok(());
    }

    // Transcode to a temporary file if necessary
    match image_needs_conversion(&config, &input_path) {
        Ok(true) => {
            if config.debug() {
                println!(" - converting to rgb8 jpg");
            }
            let i = Image::<u8, image2::Rgb>::open(&input_path)
                .map_err(|_e| PwwError::InvalidImage)?;
            let temp_dir = config.temp_dir().clone()
                .unwrap_or(PathBuf::from(std::env::temp_dir()));
            tmpfile = Some(tempfile::Builder::new()
                           .prefix("pww-")
                           .suffix(".jpg")
                           .rand_bytes(8)
                           .keep(config.keep_converted())
                           .tempfile_in(temp_dir)
                           .map_err(|e| PwwError::Unknown(format!("Failed to create temp file for image conversion: {}", e)))?);
            let outpath = tmpfile.as_ref().map(|x| x.path().to_path_buf())
                .ok_or_else(|| PwwError::Unknown(format!("Unable to open temp file for image conversion.")))?;
            // convert to Rgb8
            let conv = image2::filter::convert();
            let i: Image<u8, image2::Rgb> = i.run(conv, None);
            let max_dimension = std::cmp::max(i.width(), i.height());
            let min_dimension = std::cmp::min(i.width(), i.height());
            let should_scale = match config.downscale_policy {
                DownscalePolicy::Always => {
                    true
                },
                DownscalePolicy::ConvertedOnly => {
                    true
                },
                DownscalePolicy::LargeOrConverted => {
                    true
                }
                DownscalePolicy::Large => {
                    max_dimension as u32 > config.max_dimension()
                }
                DownscalePolicy::Never => {
                    false
                }
            };
            if should_scale && min_dimension as u32 > config.min_dimension() {
                let scale = config.min_dimension() as f64 / min_dimension as f64;
                if config.debug() {
                    println!(" - scaling by {:.2} ({} to {})", scale, min_dimension, config.min_dimension());
                }
                i.scale(scale, scale);
            }
            i.save(&outpath)
                .map_err(|e| PwwError::Unknown(format!("Unable to save converted image file: {}", e)))?;
            analyze_path = outpath;
        },
        Err(e) => {
            eprintln!("Error parsing input image: {}", e.to_string());
        }
        _ => {},
    }

    // run ML engine on file
    if config.debug() {
        if analyze_path != input_path {
            println!(" - converted to: {}", analyze_path.to_string_lossy())
        }
    }
    let raw_output = exec_identifier(&config, &analyze_path)
        .map_err(|e| PwwError::Unknown(format!("Identifier binary failed to execute: {}", e)))?;

    // convert string output to list of tags
    let tags = normalized_tags(&config, raw_output);
    if config.verbose() {
        println!(" - tags: {}", tags.join(", "));
    }

    match should_update_sidecar(&config, &input_path) {
        Ok(Some(sidecar_path)) => {
            write_tags_to_file(&config, &config.sidecar_tags, &tags, &sidecar_path)?;
        },
        Err(e) => {
            return Err(PwwError::Unknown(format!("Error: XMP file required but not found: {}", e)));
        },
        _ => {
        }
    }

    match should_update_image(&config, &input_path) {
        Ok(true) => {
            write_tags_to_file(&config, &config.image_tags, &tags, &input_path)?;
        },
        Err(e) => {
            return Err(PwwError::Unknown(format!("Error: Unable to determine if EXIF should be written: {}", e)));
        },
        _ => {
        }
    }

    if config.file_update_policy() == FileUpdatePolicy::DisplayOnly {
        println!("{}", tags.join(", "));
    }

    Ok(())
}

fn main() -> Result<(), PwwError> {
    let mut config: PwwConfig = confy::load("photo-what-what", "pww_config")
        .map_err(|e| PwwError::Unknown(format!("Error loading config file: {}", e)))?;
    config.cli = Some(PwwArgs::parse());

    let mut count = 0;
    for input_path in config.image_paths() {
        match process_image(&config, &input_path) {
            Err(e) => {
                println!("Error processing image ({}): {}", input_path.to_string_lossy(), e);
                if config.halt_on_error() {
                    std::process::exit(1);
                }
                if config.verbose() || config.debug() {
                    println!();
                }
            },
            _ => {
                count += 1;
            }
        }
    }

    if count == 0 {
        println!("Error: no images processed.");
        std::process::exit(1);
    }

    Ok(())
}