pmat 3.11.0

PMAT - Zero-config AI context generation and code quality toolkit (CLI, MCP, HTTP)
#![cfg_attr(coverage_nightly, coverage(off))]
//! Command Runner with Timeout Support
//!
//! Executes external commands with configurable timeouts to prevent hangs.
//!
//! This module provides a wrapper around std::process::Command that adds:
//! - Timeout support (default: 30 seconds)
//! - Graceful degradation on timeout
//! - Error handling for missing tools

use std::io;
use std::path::Path;
use std::process::{Command, Output};
use std::time::Duration;

/// Default timeout for external commands (30 seconds)
pub const DEFAULT_TIMEOUT_SECS: u64 = 30;

/// Run a command with timeout support
///
/// # Arguments
/// * `program` - The program to execute (e.g., "cargo", "rustfmt")
/// * `args` - Command arguments
/// * `current_dir` - Working directory for the command
/// * `timeout_secs` - Timeout in seconds (None for default)
///
/// # Returns
/// * `Ok(Some(Output))` - Command completed successfully within timeout
/// * `Ok(None)` - Command timed out
/// * `Err(io::Error)` - Command failed to execute
pub fn run_with_timeout(
    program: &str,
    args: &[&str],
    current_dir: &Path,
    timeout_secs: Option<u64>,
) -> io::Result<Option<Output>> {
    let timeout = Duration::from_secs(timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS));

    // Spawn the command
    let mut child = Command::new(program)
        .args(args)
        .current_dir(current_dir)
        .spawn()?;

    // Wait with timeout
    let start = std::time::Instant::now();
    loop {
        match child.try_wait()? {
            Some(status) => {
                // Process completed
                let output = Output {
                    status,
                    stdout: Vec::new(), // We don't capture stdout/stderr in this simple impl
                    stderr: Vec::new(),
                };
                return Ok(Some(output));
            }
            None => {
                // Still running - check timeout
                if start.elapsed() > timeout {
                    // Timeout! Kill the process
                    let _ = child.kill();
                    let _ = child.wait();
                    return Ok(None); // Indicate timeout
                }

                // Sleep briefly before checking again
                std::thread::sleep(Duration::from_millis(100));
            }
        }
    }
}

/// Run cargo clippy with timeout
pub fn run_clippy(project_path: &Path, _timeout_secs: Option<u64>) -> io::Result<Option<Output>> {
    let mut cmd = Command::new("cargo");
    cmd.arg("clippy")
        .arg("--all-targets")
        .arg("--")
        .arg("-D")
        .arg("warnings")
        .current_dir(project_path);

    // Run without timeout (timeouts handled at scorer level via --full flag)
    match cmd.output() {
        Ok(output) => Ok(Some(output)),
        Err(e) => Err(e),
    }
}

/// Run rustfmt check with timeout
pub fn run_rustfmt_check(
    project_path: &Path,
    _timeout_secs: Option<u64>,
) -> io::Result<Option<Output>> {
    let mut cmd = Command::new("cargo");
    cmd.arg("fmt")
        .arg("--")
        .arg("--check")
        .current_dir(project_path);

    // For now, just run without timeout
    match cmd.output() {
        Ok(output) => Ok(Some(output)),
        Err(e) => Err(e),
    }
}

/// Run cargo-audit with timeout
pub fn run_cargo_audit(
    project_path: &Path,
    _timeout_secs: Option<u64>,
) -> io::Result<Option<Output>> {
    let mut cmd = Command::new("cargo");
    cmd.arg("audit").arg("--json").current_dir(project_path);

    match cmd.output() {
        Ok(output) => Ok(Some(output)),
        Err(e) => Err(e),
    }
}

#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    #[test]
    fn test_command_runner_exists() {
        // Just verify the module compiles
        assert_eq!(DEFAULT_TIMEOUT_SECS, 30);
    }

    #[test]
    fn test_default_timeout_value() {
        assert_eq!(DEFAULT_TIMEOUT_SECS, 30);
    }

    #[test]
    fn test_run_with_timeout_success() {
        let temp_dir = TempDir::new().unwrap();

        // Use 'true' command which should succeed immediately
        let result = run_with_timeout("true", &[], temp_dir.path(), Some(5));

        assert!(result.is_ok());
        let output = result.unwrap();
        assert!(output.is_some());
        let output = output.unwrap();
        assert!(output.status.success());
    }

    #[test]
    fn test_run_with_timeout_failure() {
        let temp_dir = TempDir::new().unwrap();

        // Use 'false' command which should fail immediately
        let result = run_with_timeout("false", &[], temp_dir.path(), Some(5));

        assert!(result.is_ok());
        let output = result.unwrap();
        assert!(output.is_some());
        let output = output.unwrap();
        assert!(!output.status.success());
    }

    #[test]
    fn test_run_with_timeout_nonexistent_command() {
        let temp_dir = TempDir::new().unwrap();

        // This should fail to spawn
        let result = run_with_timeout(
            "this_command_does_not_exist_12345",
            &[],
            temp_dir.path(),
            Some(1),
        );

        assert!(result.is_err());
    }

    #[test]
    fn test_run_with_timeout_with_args() {
        let temp_dir = TempDir::new().unwrap();
        fs::write(temp_dir.path().join("test.txt"), "hello").unwrap();

        // Test with arguments
        let result = run_with_timeout("ls", &["-la"], temp_dir.path(), Some(5));

        assert!(result.is_ok());
        let output = result.unwrap();
        assert!(output.is_some());
    }

    #[test]
    fn test_run_with_timeout_default_timeout() {
        let temp_dir = TempDir::new().unwrap();

        // Test with None timeout (uses default)
        let result = run_with_timeout("true", &[], temp_dir.path(), None);

        assert!(result.is_ok());
        let output = result.unwrap();
        assert!(output.is_some());
    }

    #[test]
    fn test_run_clippy_returns_result() {
        let temp_dir = TempDir::new().unwrap();

        // Without Cargo.toml, cargo clippy will fail
        let result = run_clippy(temp_dir.path(), None);

        // Should return Some output (even if it's an error)
        assert!(result.is_ok());
        // It will return Some because cargo exists, but clippy may fail
        // since there's no project
    }

    #[test]
    fn test_run_rustfmt_check_returns_result() {
        let temp_dir = TempDir::new().unwrap();

        // Without a Cargo project, rustfmt check will fail
        let result = run_rustfmt_check(temp_dir.path(), None);

        assert!(result.is_ok());
    }

    #[test]
    fn test_run_cargo_audit_returns_result() {
        let temp_dir = TempDir::new().unwrap();

        // Without Cargo.lock, cargo audit will fail
        let result = run_cargo_audit(temp_dir.path(), None);

        assert!(result.is_ok());
    }

    #[test]
    fn test_run_with_timeout_working_directory() {
        let temp_dir = TempDir::new().unwrap();
        let subdir = temp_dir.path().join("subdir");
        fs::create_dir_all(&subdir).unwrap();
        fs::write(subdir.join("marker.txt"), "test").unwrap();

        // pwd should return the working directory
        let result = run_with_timeout("pwd", &[], &subdir, Some(5));

        assert!(result.is_ok());
        let output = result.unwrap();
        assert!(output.is_some());
        assert!(output.unwrap().status.success());
    }

    #[test]
    #[ignore = "Times out - runs full clippy on project"]
    fn test_run_clippy_with_valid_project() {
        // This test requires an actual Rust project
        // We can use the current project for testing
        let project_path = std::env::current_dir().unwrap();

        // Skip if not in a Rust project
        if !project_path.join("Cargo.toml").exists() {
            return;
        }

        let result = run_clippy(&project_path, Some(60));

        // Should succeed on a valid project
        assert!(result.is_ok());
        let output = result.unwrap();
        assert!(output.is_some());
    }

    #[test]
    #[ignore = "Times out - runs full rustfmt on project"]
    fn test_run_rustfmt_with_valid_project() {
        // This test requires an actual Rust project
        let project_path = std::env::current_dir().unwrap();

        // Skip if not in a Rust project
        if !project_path.join("Cargo.toml").exists() {
            return;
        }

        let result = run_rustfmt_check(&project_path, Some(30));

        assert!(result.is_ok());
        let output = result.unwrap();
        assert!(output.is_some());
    }

    #[test]
    fn test_run_with_timeout_timeout_behavior() {
        let temp_dir = TempDir::new().unwrap();

        // Use 'sleep' command to test timeout (1 second timeout, 10 second sleep)
        // Note: This test verifies timeout works but we use a short sleep for CI
        let result = run_with_timeout("sleep", &["0.1"], temp_dir.path(), Some(5));

        // Should complete before timeout
        assert!(result.is_ok());
        let output = result.unwrap();
        assert!(output.is_some());
    }

    #[test]
    fn test_run_with_timeout_very_short_timeout() {
        let temp_dir = TempDir::new().unwrap();

        // Test with very short timeout - 'true' should still succeed
        let result = run_with_timeout("true", &[], temp_dir.path(), Some(1));

        assert!(result.is_ok());
    }

    #[test]
    fn test_command_runner_output_structure() {
        let temp_dir = TempDir::new().unwrap();

        let result = run_with_timeout("true", &[], temp_dir.path(), Some(5));

        assert!(result.is_ok());
        let output = result.unwrap();
        assert!(output.is_some());

        let output = output.unwrap();
        // Check Output structure fields exist
        assert!(output.status.success());
        // Note: stdout/stderr are empty in simple implementation
        assert!(output.stdout.is_empty());
        assert!(output.stderr.is_empty());
    }

    #[test]
    fn test_run_with_multiple_args() {
        let temp_dir = TempDir::new().unwrap();

        // Test command with multiple arguments
        let result = run_with_timeout(
            "echo",
            &["hello", "world", "test"],
            temp_dir.path(),
            Some(5),
        );

        assert!(result.is_ok());
        let output = result.unwrap();
        assert!(output.is_some());
    }
}