wallswitch 0.56.0

randomly selects wallpapers for multiple monitors
Documentation
mod args;
mod awww;
mod backends;
mod colors;
mod config;
mod dependencies;
mod desktop;
mod detector;
mod dimension;
mod environment;
mod error;
mod fileinfo;
mod list;
mod monitors;
mod orientation;
mod pids;
mod random;
mod state;
mod traits;
mod walkdir;

pub use self::{
    args::*, awww::*, backends::*, colors::*, config::*, dependencies::*, desktop::*, detector::*,
    dimension::*, environment::*, error::*, fileinfo::*, list::*, monitors::*, orientation::*,
    pids::*, random::*, state::*, traits::*, walkdir::*,
};

// use rayon::prelude::*;
use std::{
    env,
    io::{self, Write},                     // For real-time terminal flushing
    sync::atomic::{AtomicUsize, Ordering}, // For thread-safe counting
    thread,
};

/// Show initial messages
pub fn show_initial_msgs(config: &Config) -> WallSwitchResult<()> {
    let pkg_name = ENVIRON.get_pkg_name();
    let pkg_desc = env!("CARGO_PKG_DESCRIPTION");
    let pkg_version = env!("CARGO_PKG_VERSION");
    let interval = config.interval;
    let info = format!("Interval between each wallpaper: {interval} seconds.");
    let author = "Claudio Fernandes de Souza Rodrigues (claudiofsrodrigues@gmail.com)";

    println!("{pkg_name} {pkg_desc}\n{info}\n{author}");
    println!("version: {pkg_version}\n");

    let depend1 = "imagemagick (image viewing/manipulation program)";
    let depend2 = "feh (fast and light image viewer for X11/Openbox)";
    let depend3 = "awww (animated Wayland wallpaper daemon)";
    let depend4 = "swaybg (wallpaper utility for Wayland compositors)";
    let depend5 = "hyprpaper (wallpaper utility for Hyprland)";

    let dependencies = [depend1, depend2, depend3, depend4, depend5];

    println!("Dependencies:");
    dependencies.print_with_spaces(" ");
    println!();

    config.print()?;

    Ok(())
}

/// Get unique and random images filtering against history
pub fn get_images(config: &Config, state: &mut State) -> WallSwitchResult<Vec<FileInfo>> {
    let images: Vec<FileInfo> = gather_files(config, state)?;

    if images.is_empty() {
        let directories = config.directories.clone();
        return Err(WallSwitchError::NoImages { paths: directories });
    }

    // Filtra as imagens que já estão no histórico recente
    let mut pool: Vec<FileInfo> = images
        .iter()
        .filter(|img| !state.history.contains(&img.path))
        .cloned()
        .collect();

    let nimages: usize = pool.len();

    // Se o pool esgotou (todas já foram vistas), recicla o histórico
    if nimages < config.monitors.len() {
        if config.verbose {
            println!("Image pool exhausted. Resetting history cycle.");
        }
        state.history.clear();
        pool = images.clone();
    }

    pool.update_number();

    if !config.sort {
        pool.shuffle();
    }

    Ok(pool)
}

/// Gather the files with Smart Caching and Visual Deduplication
pub fn gather_files(config: &Config, state: &mut State) -> WallSwitchResult<Vec<FileInfo>> {
    state.garbage_collect();

    let mut raw_files = Vec::new();
    for dir in &config.directories {
        raw_files.extend(get_files_from_directory(dir, config)?);
    }

    let mut needs_hash = Vec::new();
    let mut cached_files = Vec::new();

    for mut file in raw_files {
        if let Some(cache) = state.hashes.get(&file.path)
            && cache.size == file.size
            && cache.mtime == file.mtime
        {
            file.hash = cache.hash.clone();
            // Idiomatically assigning the Option<Dimension>
            file.dimension = cache.dimension.clone();
            cached_files.push(file);
            continue;
        }
        needs_hash.push(file);
    }

    if !needs_hash.is_empty() {
        if config.verbose {
            println!(
                "Calculating deep BLAKE3 hashes for {} new/modified files...",
                needs_hash.len()
            );
        }
        needs_hash.update_hash()?;
    }

    for file in &needs_hash {
        state.hashes.insert(
            file.path.clone(),
            CacheEntry {
                size: file.size,
                mtime: file.mtime,
                hash: file.hash.clone(),
                dimension: file.dimension.clone(),
            },
        );
    }

    let all_files = cached_files.into_iter().chain(needs_hash);
    let mut files = Vec::new();
    let mut seen_hashes = std::collections::HashSet::new();

    for file in all_files {
        if seen_hashes.insert(file.hash.clone()) {
            files.push(file);
        } else if config.verbose {
            println!("Visual duplicate ignored: {:?}", file.path);
        }
    }

    Ok(files)
}

/// Display found images
pub fn display_files(files: &[FileInfo], config: &Config) {
    let nfiles = files.len();
    if nfiles == 0 {
        return;
    }

    let ndigits = nfiles.to_string().len();

    if config.sort {
        println!(
            "\n{} images were found (sorted):",
            nfiles.to_string().green().bold()
        );
    } else {
        println!(
            "\n{} images were found (shuffled):",
            nfiles.to_string().green().bold()
        );
    }

    for file in files {
        println!(
            "images[{n:0ndigits$}/{t}]: {p:?}",
            n = file.number,
            p = file.path,
            t = file.total,
        );
    }
    println!();
}

/**
Update FileInfo images with dimension information safely and concurrently.

This implementation follows the DRY principle by utilizing the Smart Cache:
1. It filters only images that lack dimension data (Option is None).
2. It uses ConcurrencyExt to limit parallel 'identify' processes to CPU core count.
3. It saves the results back to the persistent state, ensuring future calls are O(1).
*/
pub fn update_images(files: &[FileInfo], config: &Config, state: &mut State) -> Vec<FileInfo> {
    let mut owned_files: Vec<FileInfo> = files.to_vec();

    let mut needs_update: Vec<&mut FileInfo> = owned_files
        .iter_mut()
        .filter(|file| file.dimension.is_none())
        .collect();

    if !needs_update.is_empty() {
        let total_to_probe = needs_update.len();
        let counter = AtomicUsize::new(0); // Thread-safe progress counter

        if config.verbose {
            println!("Probing dimensions for {} new files...", total_to_probe);
        }

        let chunk_size = needs_update.get_chunk_size(total_to_probe);

        thread::scope(|scope| {
            for chunk in needs_update.chunks_mut(chunk_size) {
                scope.spawn(|| {
                    for file in chunk {
                        if file.update_info(config).is_ok() {
                            // Increment counter and get the current value
                            let current = counter.fetch_add(1, Ordering::SeqCst) + 1;

                            let file_name =
                                file.path.file_name().unwrap_or_default().to_string_lossy();

                            // Print progress: [current/total] file_path
                            // Highly readable and idiomatic:
                            // 1. Create the message string.
                            // 2. Apply the 'to_line_start' transformation.
                            let msg = format!(
                                "Probing image [{current: >4}/{total_to_probe}] : {file_name}"
                            )
                            .to_line_start();

                            print!("{msg}");
                            let _ = io::stdout().flush();
                        }
                    }
                });
            }
        });

        // Clean the progress line after finishing
        println!("\nProbing completed.\n");

        // Sync with state cache
        let mut state_changed = false;
        for file in &owned_files {
            if let Some(dim) = &file.dimension
                && let Some(entry) = state.hashes.get_mut(&file.path)
                && entry.dimension.is_none()
            {
                entry.dimension = Some(dim.clone());
                state_changed = true;
            }
        }

        if state_changed {
            let _ = state.save();
        }
    }

    owned_files
}

//----------------------------------------------------------------------------//
//                                   Tests                                    //
//----------------------------------------------------------------------------//

/// Run tests with:
/// cargo test -- --show-output tests_lib
#[cfg(test)]
mod test_lib {
    use crate::*;

    #[test]
    /// `cargo test -- --show-output vec_shuffle`
    fn vec_shuffle() {
        let mut vec: Vec<u32> = (1..=100).collect();
        vec.shuffle();

        println!("vec: {vec:?}");
        assert_eq!(vec.len(), 100);
    }

    #[test]
    /// `cargo test -- --show-output random_integers_v1`
    ///
    /// <https://stackoverflow.com/questions/48218459/how-do-i-generate-a-vector-of-random-numbers-in-a-range>
    fn random_integers_v1() {
        // Example: Get a random integer value in the range 1 to 20:
        let value: u64 = get_random_integer(1, 20);

        println!("integer: {value:?}");

        // Generate a vector of 100 64-bit integer values in the range from 1 to 20,
        // allowing duplicates:

        let integers: Vec<u64> = (0..100).map(|_| get_random_integer(1, 20)).collect();

        println!("integers: {integers:?}");

        let condition_a = integers.iter().min() >= Some(&1);
        let condition_b = integers.iter().max() <= Some(&20);

        assert!(condition_a);
        assert!(condition_b);
        assert_eq!(integers.len(), 100);
    }

    #[test]
    /// `cargo test -- --show-output random_integers_v2`
    ///
    /// <https://stackoverflow.com/questions/48218459/how-do-i-generate-a-vector-of-random-numbers-in-a-range>
    fn random_integers_v2() -> WallSwitchResult<()> {
        // Example: Get a random integer value in the range 1 to 20:
        let value: u64 = get_random_integer_safe(1, 20)?;

        println!("integer: {value:?}");

        // Generate a vector of 100 64-bit integer values in the range from 1 to 20,
        // allowing duplicates:

        let integers: Vec<u64> = (0..100)
            .map(|_| get_random_integer_safe(1, 20))
            .collect::<Result<Vec<u64>, _>>()?;

        println!("integers: {integers:?}");

        let condition_a = integers.iter().min() >= Some(&1);
        let condition_b = integers.iter().max() <= Some(&20);

        assert!(condition_a);
        assert!(condition_b);
        assert_eq!(integers.len(), 100);

        Ok(())
    }

    #[test]
    /// `cargo test -- --show-output random_integers_v3`
    fn random_integers_v3() -> WallSwitchResult<()> {
        let result = get_random_integer_safe(21, 20).map_err(|err| {
            eprintln!("{err}");
            err
        });
        assert!(result.is_err());

        let error = result.unwrap_err();
        eprintln!("error: {error:?}");

        Ok(())
    }
}