wallswitch 0.58.0

randomly selects wallpapers for multiple monitors
Documentation
use crate::{
    Config, Countable, Dimension, DimensionError, WallSwitchError, WallSwitchResult,
    compute_hashes_parallel, probe_image_dimension,
};
use std::{fmt, path::PathBuf};

// ==============================================================================
// DOMAIN ENTITY: Pure Data Structures & Business Rules
// ==============================================================================

/// Image information representing a wallpaper candidate.
///
/// This struct acts as a Domain Entity. It stores purely metadata and state.
#[derive(Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Clone)]
pub struct FileInfo {
    /// File number (index + 1) used for display indexing
    pub number: usize,
    /// Total number of files in the current operation
    pub total: usize,
    /// Dimension: width x height of an image.
    pub dimension: Option<Dimension>,
    /// Evaluated dynamically against the current Config (size and dimensions).
    /// Indicates if the image meets all criteria.
    pub is_valid: Option<bool>,
    /// The size of the file, in bytes
    pub size: u64,
    /// Unix Timestamp of the last modification (mtime)
    pub mtime: u64,
    /// BLAKE3 Hash of the file contents for visual deduplication
    pub hash: String,
    /// The physical file path of the image
    pub path: PathBuf,
}

impl FileInfo {
    /// PURE FUNCTION: Returns true if the given pattern matches a sub-slice of this path.
    pub fn path_contains(&self, string: &str) -> bool {
        match self.path.to_str() {
            Some(p) => p.contains(string),
            None => false,
        }
    }

    // --------------------------------------------------------------------------
    // PURE VALIDATION LOGIC (No I/O, No Side Effects)
    // --------------------------------------------------------------------------

    /// PURE FUNCTION: Validates if the dimension satisfies the configured boundaries.
    /// Returns `None` if the dimension hasn't been probed yet.
    pub fn check_dimension(&self, config: &Config) -> Option<Result<(), DimensionError>> {
        let dim = self.dimension.as_ref()?;

        if dim.is_valid(config) {
            Some(Ok(()))
        } else {
            Some(Err(DimensionError::DimensionFormatError {
                dimension: dim.clone(),
                log_min: dim.get_log_min(config),
                log_max: dim.get_log_max(config),
                path: self.path.clone(),
            }))
        }
    }

    /// PURE FUNCTION: Validates if the file name conflicts with the generated wallpaper path.
    pub fn check_name(&self, config: &Config) -> Result<(), WallSwitchError> {
        if self.path.file_name() != config.wallpaper.file_name() {
            Ok(())
        } else {
            Err(WallSwitchError::InvalidFilename(self.path.clone()))
        }
    }

    /// PURE FUNCTION: Validates if the file size is within the allowed limits.
    pub fn check_size(&self, config: &Config) -> Result<(), WallSwitchError> {
        if self.size >= config.min_size && self.size <= config.max_size {
            Ok(())
        } else {
            Err(WallSwitchError::InvalidSize {
                min_size: config.min_size,
                size: self.size,
                max_size: config.max_size,
            })
        }
    }

    // --------------------------------------------------------------------------
    // IMPURE FACADES (Maintains compatibility & handles Terminal Logging)
    // --------------------------------------------------------------------------

    /// FACADE: Delegates the OS-level ImageMagick call to the infrastructure layer.
    pub fn update_info(&mut self, config: &Config) -> WallSwitchResult<()> {
        self.dimension = Some(probe_image_dimension(&self.path, config.verbose)?);
        Ok(())
    }

    /// FACADE: Evaluates the pure `check_dimension` function and prints errors to the terminal if needed.
    pub fn dimension_is_valid(&self, config: &Config) -> bool {
        match self.check_dimension(config) {
            Some(Ok(())) => true,
            Some(Err(err)) => {
                if config.verbose {
                    eprintln!("{}", WallSwitchError::InvalidDimension(err));
                }
                false
            }
            None => false, // Invalid state if it reaches here without being probed
        }
    }

    /// FACADE: Evaluates the pure `check_name` function and prints errors to the terminal if needed.
    pub fn name_is_valid(&self, config: &Config) -> bool {
        match self.check_name(config) {
            Ok(()) => true,
            Err(err) => {
                if config.verbose {
                    eprintln!("{}\n", err);
                }
                false
            }
        }
    }

    /// FACADE: Evaluates the pure `check_size` function and prints errors to the terminal if needed.
    pub fn size_is_valid(&self, config: &Config) -> bool {
        match self.check_size(config) {
            Ok(()) => true,
            Err(err) => {
                if config.verbose {
                    // Display file info before the error to match original behavior
                    println!("{}", SliceDisplay(std::slice::from_ref(self)));
                    eprintln!("{}", err);
                    eprintln!("{}\n", WallSwitchError::DisregardPath(self.path.clone()));
                }
                false
            }
        }
    }
}

// ==============================================================================
// DOMAIN LOGIC: Slice Extensions
// ==============================================================================

/// Extension methods for slices of `FileInfo`
pub trait FileInfoExt {
    fn get_width_min(&self) -> Option<u64>;
    fn get_max_size(&self) -> Option<u64>;
    fn get_max_number(&self) -> Option<usize>;
    fn get_max_dimension(&self) -> Option<u64>;
    fn sizes_are_valid(&self, config: &Config) -> bool;
    fn update_number(&mut self);
    fn update_hash(&mut self) -> WallSwitchResult<()>;
}

impl FileInfoExt for [FileInfo] {
    // --- PURE COLLECTION QUERIES ---

    fn get_width_min(&self) -> Option<u64> {
        self.iter()
            .filter_map(|f| f.dimension.as_ref().map(|d| d.width))
            .min()
    }

    fn get_max_size(&self) -> Option<u64> {
        self.iter().map(|file_info| file_info.size).max()
    }

    fn get_max_number(&self) -> Option<usize> {
        self.iter().map(|file_info| file_info.number).max()
    }

    fn get_max_dimension(&self) -> Option<u64> {
        self.iter()
            .filter_map(|f| f.dimension.as_ref().map(|d| d.maximum()))
            .max()
    }

    fn update_number(&mut self) {
        let total = self.len();
        self.iter_mut().enumerate().for_each(|(index, file)| {
            file.number = index + 1;
            file.total = total;
        });
    }

    // --- IMPURE ORCHESTRATORS ---

    /// Iterates over all files and checks if their sizes are valid, printing errors for failures.
    fn sizes_are_valid(&self, config: &Config) -> bool {
        self.iter().all(|file_info| {
            let is_valid = file_info.size_is_valid(config);

            if !is_valid {
                // Ensure the slice header context is printed along with the error
                print!("{}", SliceDisplay(self));
            }
            is_valid
        })
    }

    /// FACADE: Delegates parallel hash computation to the infrastructure layer.
    fn update_hash(&mut self) -> WallSwitchResult<()> {
        compute_hashes_parallel(self);
        Ok(())
    }
}

// ==============================================================================
// PRESENTATION / UI: Formatting & Display
// ==============================================================================

/// Wrapper struct to implement fmt::Display for a slice of `FileInfo`.
///
/// References:
/// <https://stackoverflow.com/questions/30633177/implement-fmtdisplay-for-vect>
/// <https://gist.github.com/hyone/d6018ee1ac8f9496fed839f481eb59d6>
pub struct SliceDisplay<'a>(pub &'a [FileInfo]);

impl fmt::Display for SliceDisplay<'_> {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let digits_n: Option<usize> = self.0.get_max_number().map(|n| n.count_chars());
        let digits_s: Option<usize> = self.0.get_max_size().map(|s| s.count_chars());
        let digits_d: Option<usize> = self.0.get_max_dimension().map(|d| d.count_chars());

        match (digits_n, digits_s, digits_d) {
            (Some(num_digits_number), Some(num_digits_size), num_digits_dimension) => {
                let d_padding = num_digits_dimension.unwrap_or(4); // default padding if None

                for file in self.0 {
                    let dim_str = match &file.dimension {
                        Some(dim) => format!(
                            "Dimension {{ width: {width:>d$}, height: {height:>d$} }}",
                            width = dim.width,
                            height = dim.height,
                            d = d_padding,
                        ),
                        // Cleaner "Unprobed" or "Error" state display
                        None => format!(
                            "Dimension {{ {:>width$} }}",
                            "Pending probe",
                            width = d_padding * 2 + 13
                        ),
                    };

                    writeln!(
                        f,
                        "images[{number:0n$}/{t}]: {dim_str}, size: {size:>s$}, path: {p:?}",
                        number = file.number,
                        n = num_digits_number,
                        t = file.total,
                        size = file.size,
                        s = num_digits_size,
                        p = file.path,
                    )?;
                }
            }
            _ => return Err(std::fmt::Error),
        }

        Ok(())
    }
}

// ==============================================================================
// TESTS
// ==============================================================================

#[cfg(test)]
mod test_info {
    #[test]
    /// cargo test -- --show-output get_min_value_of_vec
    fn get_min_value_of_vec_v1() {
        let values: Vec<i32> = vec![5, 6, 8, 4, 2, 7];

        let min_value: Option<i32> = values.iter().min().copied();

        println!("values: {values:?}");
        println!("min_value: {min_value:?}");

        assert_eq!(min_value, Some(2));
    }

    #[test]
    /// `cargo test -- --show-output get_min_value_of_vec_v2`
    ///
    /// <https://stackoverflow.com/questions/58669865/how-to-get-the-minimum-value-within-a-vector-in-rust>
    fn get_min_value_of_vec_v2() {
        let values: Vec<i32> = vec![5, 6, 8, 4, 2, 7];

        // The empty vector must be filtered beforehand!
        // let values: Vec<i32> = vec![]; // Not work!!!

        // Get the minimum value without being wrapped by Option<T>
        let min_value: i32 = values
            .iter()
            .fold(i32::MAX, |arg0: i32, other: &i32| i32::min(arg0, *other));

        println!("values: {values:?}");
        println!("min_value: {min_value}");

        assert_eq!(min_value, 2);
    }
}