dotstate 0.3.4

A modern, secure, and user-friendly dotfile manager built with Rust
Documentation
use crate::utils::package_manager::PackageManagerImpl;
use crate::utils::profile_manifest::Package;
use anyhow::Result;
use std::io::{BufRead, BufReader};
use std::process::{Child, Stdio};
use std::sync::mpsc;
use std::thread;
use tracing::debug;

/// Package installer and checker utilities
pub struct PackageInstaller;

/// Installation process handle for non-blocking operations
#[allow(dead_code)] // Reserved for future use
pub struct InstallationHandle {
    pub child: Child,
    pub output_rx: mpsc::Receiver<String>,
}

use crate::ui::InstallationStatus;

impl PackageInstaller {
    /// Synchronous install that streams status to a sender
    pub fn install(package: &Package, tx: mpsc::Sender<InstallationStatus>) {
        match Self::start_install(package) {
            Ok(handle) => {
                let InstallationHandle {
                    mut child,
                    output_rx,
                } = handle;
                // Stream output
                for line in output_rx {
                    let _ = tx.send(InstallationStatus::Output(line));
                }
                // Wait for process to finish
                match child.wait() {
                    Ok(status) => {
                        let success = status.success();
                        let error = if success {
                            None
                        } else {
                            Some("Installation failed".to_string())
                        };
                        let _ = tx.send(InstallationStatus::Complete { success, error });
                    }
                    Err(e) => {
                        let _ = tx.send(InstallationStatus::Complete {
                            success: false,
                            error: Some(e.to_string()),
                        });
                    }
                }
            }
            Err(e) => {
                let _ = tx.send(InstallationStatus::Complete {
                    success: false,
                    error: Some(e.to_string()),
                });
            }
        }
    }

    /// Start installation process (non-blocking)
    /// Returns a handle that can be used to check progress and read output
    /// The caller is responsible for checking if the process is done and reading output
    #[allow(dead_code)] // Reserved for future use
    pub fn start_install(package: &Package) -> Result<InstallationHandle> {
        // Check if sudo is required and if password is needed
        if PackageManagerImpl::check_sudo_required(&package.manager) {
            return Err(anyhow::anyhow!(
                "sudo password required. Please run this in a terminal or configure passwordless sudo."
            ));
        }

        // For custom packages, check if the command contains sudo
        if matches!(
            package.manager,
            crate::utils::profile_manifest::PackageManager::Custom
        ) {
            if let Some(cmd_str) = &package.install_command {
                if cmd_str.contains("sudo") {
                    // Check if sudo password is required
                    let sudo_needs_password = std::process::Command::new("sudo")
                        .arg("-n")
                        .arg("true")
                        .output()
                        .map(|o| !o.status.success())
                        .unwrap_or(true);

                    if sudo_needs_password {
                        return Err(anyhow::anyhow!(
                            "This command requires sudo password. Please run it manually in a terminal:\n\n  {cmd_str}"
                        ));
                    }
                }
            }
        }

        // Build command (direct Command for managed, sh -c for custom)
        let mut cmd = PackageManagerImpl::get_install_command_builder(package);

        // Use null stdin to prevent interactive commands from hanging
        // (they'll fail immediately instead of waiting for input)
        let mut child = cmd
            .stdin(Stdio::null())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()?;

        // Channel for output lines
        let (tx, rx) = mpsc::channel::<String>();
        let tx_stdout = tx.clone();
        let tx_stderr = tx.clone();

        // Spawn thread to read stdout
        let stdout = child
            .stdout
            .take()
            .ok_or_else(|| anyhow::anyhow!("Failed to capture stdout"))?;
        thread::spawn(move || {
            let reader = BufReader::new(stdout);
            #[allow(clippy::unnecessary_lazy_evaluations, clippy::lines_filter_map_ok)]
            for line in reader.lines().flatten() {
                let _ = tx_stdout.send(line);
            }
        });

        // Spawn thread to read stderr
        let stderr = child
            .stderr
            .take()
            .ok_or_else(|| anyhow::anyhow!("Failed to capture stderr"))?;
        thread::spawn(move || {
            let reader = BufReader::new(stderr);
            #[allow(clippy::unnecessary_lazy_evaluations, clippy::lines_filter_map_ok)]
            for line in reader.lines().flatten() {
                let _ = tx_stderr.send(format!("[stderr] {line}"));
            }
        });

        Ok(InstallationHandle {
            child,
            output_rx: rx,
        })
    }

    /// Check if installation process is complete
    #[allow(dead_code)] // Reserved for future use
    pub fn check_installation_status(handle: &mut InstallationHandle) -> Result<Option<bool>> {
        // Try to wait for the process (non-blocking)
        match handle.child.try_wait()? {
            Some(status) => Ok(Some(status.success())),
            None => Ok(None), // Still running
        }
    }

    /// Read available output lines (non-blocking)
    #[allow(dead_code)] // Reserved for future use
    #[must_use]
    pub fn read_output(handle: &InstallationHandle) -> Vec<String> {
        let mut lines = Vec::new();
        // Try to read all available lines without blocking
        while let Ok(line) = handle.output_rx.try_recv() {
            lines.push(line);
        }
        lines
    }
    /// Check if package exists (binary check first, then manager-native fallback)
    /// Returns (exists: bool, `check_command`: Option<String>, output: Option<String>)
    ///
    /// Important: Binary check is tried FIRST regardless of manager presence.
    /// This allows packages installed manually (without manager) to be detected.
    /// Manager is only required for manager-native fallback and installation.
    pub fn check_exists(package: &Package) -> Result<(bool, Option<String>, Option<String>)> {
        // Track what checks we attempted for better error messages
        let mut check_attempts: Vec<String> = Vec::new();

        // If the user provided an explicit existence_check, treat it as the
        // canonical check and short-circuit the binary/manager fallbacks.
        if let Some(existence_check) = package.existence_check.as_ref().map(|s| s.trim()) {
            if !existence_check.is_empty() {
                debug!(
                    "Running user-provided existence_check for package {}",
                    package.name
                );
                let output = std::process::Command::new("sh")
                    .arg("-c")
                    .arg(existence_check)
                    .output()?;
                let found = output.status.success();
                let stdout = String::from_utf8_lossy(&output.stdout).to_string();
                let stderr = String::from_utf8_lossy(&output.stderr).to_string();
                let combined_output = format!("STDOUT:\n{stdout}\nSTDERR:\n{stderr}");
                debug!("existence_check for {}: {}", package.name, found);
                return Ok((
                    found,
                    Some(existence_check.to_string()),
                    Some(combined_output),
                ));
            }
        }

        // First, try binary check (no manager required)
        // This works even if package was installed manually
        debug!(
            "Checking if binary '{}' exists in PATH for package {}",
            package.binary_name, package.name
        );
        let binary_check_cmd = format!("which {}", package.binary_name);
        if PackageManagerImpl::check_binary_in_path(&package.binary_name) {
            debug!("Package {} found via binary check", package.name);
            return Ok((
                true,
                Some(binary_check_cmd),
                Some("Binary found in PATH".to_string()),
            ));
        }
        debug!("Binary '{}' not found in PATH", package.binary_name);
        check_attempts.push(format!(
            "Binary check: `{binary_check_cmd}` - not found in PATH"
        ));

        // Binary check failed, try manager-native check if available
        // This requires the manager to be installed
        if let Some(manager_check) = &package.manager_check {
            debug!("Trying custom manager check for package {}", package.name);
            // Use custom manager check command (via shell, user-provided)
            let output = std::process::Command::new("sh")
                .arg("-c")
                .arg(manager_check)
                .output()?;
            let found = output.status.success();
            let stdout = String::from_utf8_lossy(&output.stdout).to_string();
            let stderr = String::from_utf8_lossy(&output.stderr).to_string();
            let combined_output = format!("STDOUT:\n{stdout}\nSTDERR:\n{stderr}");

            debug!("Custom manager check for {}: {}", package.name, found);
            // Return the output even if check failed - user wants to see what happened
            return Ok((found, Some(manager_check.clone()), Some(combined_output)));
        }

        // Try auto-generated manager check (requires manager installed)
        if let Some(package_name) = &package.package_name {
            // Only try manager check if manager is installed
            if PackageManagerImpl::is_manager_installed(&package.manager) {
                debug!(
                    "Trying auto-generated manager check for package {} (manager: {:?})",
                    package.name, package.manager
                );
                if let Some(mut manager_cmd) =
                    PackageManagerImpl::build_manager_check_command(&package.manager, package_name)
                {
                    // Capture command string representation for cache
                    let cmd_str = format!("{manager_cmd:?}");

                    let output = manager_cmd.output()?;
                    let found = output.status.success();
                    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
                    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
                    let combined_output = format!("STDOUT:\n{stdout}\nSTDERR:\n{stderr}");

                    debug!(
                        "Auto-generated manager check for {}: {}",
                        package.name, found
                    );
                    return Ok((found, Some(cmd_str), Some(combined_output)));
                }
            } else {
                check_attempts.push(format!(
                    "Manager check: {:?} not installed, skipped",
                    package.manager
                ));
                debug!(
                    "Manager {:?} not installed, skipping manager check for {}",
                    package.manager, package.name
                );
            }
        }

        // All checks failed - show what we tried
        debug!("All checks failed for package {}", package.name);
        let output = if check_attempts.is_empty() {
            "No suitable check method found".to_string()
        } else {
            format!("Checks attempted:\n{}", check_attempts.join("\n"))
        };
        Ok((false, None, Some(output)))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::utils::profile_manifest::{Package, PackageManager};

    fn custom_pkg(name: &str, binary_name: &str, existence_check: Option<&str>) -> Package {
        Package {
            name: name.to_string(),
            description: None,
            manager: PackageManager::Custom,
            package_name: None,
            binary_name: binary_name.to_string(),
            install_command: Some("echo installed".to_string()),
            existence_check: existence_check.map(str::to_string),
            manager_check: None,
        }
    }

    #[test]
    fn existence_check_overrides_failing_binary() {
        // Issue #51: binary_name is broken, but existence_check succeeds → installed
        let pkg = custom_pkg("test-pkg", "nonexistent-binary-xyz-9001", Some("true"));
        let (found, cmd, _) = PackageInstaller::check_exists(&pkg).unwrap();
        assert!(found, "existence_check `true` should report installed");
        assert_eq!(cmd.as_deref(), Some("true"));
    }

    #[test]
    fn existence_check_overrides_passing_binary() {
        // Issue #51: binary `sh` exists in PATH, but existence_check fails → not installed
        let pkg = custom_pkg("fake-pkg", "sh", Some("false"));
        let (found, cmd, _) = PackageInstaller::check_exists(&pkg).unwrap();
        assert!(
            !found,
            "existence_check `false` should report not installed"
        );
        assert_eq!(cmd.as_deref(), Some("false"));
    }

    #[test]
    fn empty_existence_check_falls_through_to_binary() {
        // An empty/whitespace existence_check should not short-circuit.
        // `sh` is reliably in PATH on Unix; check that binary fallback still runs.
        let pkg = custom_pkg("sh-pkg", "sh", Some("   "));
        let (found, _, _) = PackageInstaller::check_exists(&pkg).unwrap();
        assert!(
            found,
            "binary check for `sh` should succeed when existence_check is empty"
        );
    }

    #[test]
    fn none_existence_check_falls_through_to_binary() {
        let pkg = custom_pkg("missing-pkg", "nonexistent-binary-xyz-9001", None);
        let (found, _, _) = PackageInstaller::check_exists(&pkg).unwrap();
        assert!(
            !found,
            "no existence_check, no binary, no manager → not installed"
        );
    }
}