wallswitch 0.63.5

randomly selects wallpapers for multiple monitors
Documentation
//! Process management and single-instance serialization using PID tracking.
//!
//! This module ensures that only a single instance of the application runs
//! concurrently by checking for an active PID file. If found, it attempts to
//! terminate the previous instance before writing its own PID atomically.
//!
//! # Note on Idioms
//! While PID files are standard in CLI tools, they have limitations (PID recycling).
//! For strict concurrency control in Rust, consider using OS-level file locking
//! (`flock` / `FileLock`) instead of this approach for higher reliability.

use crate::{Config, WallSwitchError, WallSwitchResult, get_config_path};
use std::{
    fs,
    path::{Path, PathBuf},
    process, thread,
    time::Duration,
};
use sysinfo::{Pid, ProcessesToUpdate, System};

/// The name of the application used to identify the process in system checks.
const APP_NAME: &str = "wallswitch";

/// The filename used to store the active Process ID (PID).
const PID_FILE_NAME: &str = "wallswitch.pid";

/// Scans for existing instances and writes the current PID atomically.
///
/// 1. Reads the existing PID file.
/// 2. If a valid PID is found, attempts to kill that process if it matches our app name.
/// 3. Ensures directories exist.
/// 4. Writes the *current* PID using an atomic rename operation (safe against crashes).
///
/// # Errors
/// Returns a [`WallSwitchError`] if directory creation or file operations fail.
pub fn kill_other_instances(config: &Config) -> WallSwitchResult<()> {
    if config.dry_run {
        if config.verbose {
            println!("[DRY-RUN] Skipping single-instance locking and termination.");
        }
        return Ok(());
    }

    let pid_path = get_pid_path()?;
    let current_pid = process::id();

    // 1. Check for existing instance
    if let Some(old_pid) = read_pid_file(&pid_path)?
        && old_pid != current_pid
    {
        handle_existing_instance(&pid_path, old_pid, config)?;
    }

    // 2. Ensure parent directories exist
    ensure_parent_dir_exists(&pid_path)?;

    // 3. Atomic Write Strategy:
    atomic_write_pid_file(&pid_path, current_pid)?;

    Ok(())
}

/// Checks if the process with `old_pid_u32` is still running and belongs to our app.
fn handle_existing_instance(
    pid_path: &Path,
    old_pid_u32: u32,
    config: &Config,
) -> WallSwitchResult<()> {
    let mut sys = System::new();
    let old_pid = Pid::from_u32(old_pid_u32);

    // Refresh only the specific target PID to save resources
    sys.refresh_processes(ProcessesToUpdate::Some(&[old_pid]), true);

    if let Some(process) = sys.process(old_pid) {
        let process_name = process.name().to_string_lossy().to_lowercase();

        // Safety check: ensure we only kill our own process to avoid
        // terminating unrelated processes that might have reused the PID.
        if process_name.contains(APP_NAME) {
            if config.verbose {
                println!("PID file: {}", pid_path.display());
                println!(
                    "Terminating previous instance found in PID file (PID: {})...\n",
                    old_pid_u32
                );
            }

            process.kill();

            // Give OS time to release resources/ports before we continue
            thread::sleep(Duration::from_millis(200));
        } else if config.verbose {
            println!(
                "PID {} active but process '{}' does not match '{}'. Skipping.",
                old_pid_u32, process_name, APP_NAME
            );
        }
    }

    Ok(())
}

/// Ensures the directory for a path exists.
fn ensure_parent_dir_exists(path: &Path) -> WallSwitchResult<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).map_err(|e| WallSwitchError::IOError {
            path: parent.to_path_buf(),
            io_error: e,
        })?;
    }
    Ok(())
}

/// Writes the current PID to a temporary file and atomically renames it
/// to the target path.
///
/// This approach ensures that if the process crashes during writing,
/// no partial or corrupted data is written to the final PID file.
fn atomic_write_pid_file(path: &Path, pid: u32) -> WallSwitchResult<()> {
    // 1. Atomic Write Strategy:
    let temp_pid_file = path.with_extension(format!("{}.tmp", pid));

    // 2. Write to the temporary file (safe, as it doesn't affect the final state yet)
    fs::write(&temp_pid_file, pid.to_string()).map_err(|e| WallSwitchError::IOError {
        path: temp_pid_file.clone(),
        io_error: e,
    })?;

    // 3. Atomically rename the temporary file to the target PID file
    fs::rename(&temp_pid_file, path).map_err(|e| {
        // Best Practice: Clean up the temp file if atomic move fails
        // to avoid leaving "ghost" files on disk
        let _ = fs::remove_file(&temp_pid_file);

        WallSwitchError::IOError {
            path: path.to_path_buf(),
            io_error: e,
        }
    })?;

    Ok(())
}

/// Reads the PID file. Returns `None` if empty or corrupted (allowing self-healing).
fn read_pid_file(path: &Path) -> WallSwitchResult<Option<u32>> {
    if !path.exists() || !path.metadata()?.is_file() {
        return Ok(None);
    }

    let content = fs::read_to_string(path).map_err(|e| WallSwitchError::IOError {
        path: path.to_path_buf(),
        io_error: e,
    })?;

    // Parse safely. If invalid (corrupted file), return None so we overwrite it later.
    Ok(content.trim().parse::<u32>().ok())
}

/// Resolves the absolute path to the application PID file based on config.
fn get_pid_path() -> WallSwitchResult<PathBuf> {
    let mut path = get_config_path()?;
    path.set_file_name(PID_FILE_NAME);
    Ok(path)
}

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

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

    #[test]
    fn test_read_pid_file_missing() {
        let temp_dir = std::env::temp_dir().join("wallswitch_test_missing");
        fs::create_dir_all(&temp_dir).unwrap();
        let non_existent = temp_dir.join("non_existent.pid");

        let result = read_pid_file(&non_existent);
        assert!(result.is_ok());
        assert_eq!(result.unwrap(), None);

        // Cleanup
        let _ = fs::remove_dir_all(temp_dir);
    }

    #[test]
    fn test_read_pid_file_valid() {
        let temp_dir = std::env::temp_dir().join("wallswitch_test_valid");
        fs::create_dir_all(&temp_dir).unwrap();
        let pid_file = temp_dir.join("test.pid");

        fs::write(&pid_file, "12345").unwrap();

        let result = read_pid_file(&pid_file);
        assert!(result.is_ok());
        assert_eq!(result.unwrap(), Some(12345));

        // Cleanup
        let _ = fs::remove_dir_all(temp_dir);
    }

    #[test]
    fn test_read_pid_file_invalid_content() {
        let temp_dir = std::env::temp_dir().join("wallswitch_test_invalid");
        fs::create_dir_all(&temp_dir).unwrap();
        let pid_file = temp_dir.join("invalid.pid");

        // Write garbage to simulate corruption
        fs::write(&pid_file, "not_a_number").unwrap();

        let result = read_pid_file(&pid_file);
        assert!(result.is_ok());

        // Should return None (treat as no instance) to allow overwriting
        assert_eq!(result.unwrap(), None);

        // Cleanup
        let _ = fs::remove_dir_all(temp_dir);
    }
}