compi 0.5.0

A build system written in Rust.
use blake3::Hash;
use glob::{GlobError, PatternError, glob};
use std::process::{Output, Stdio};
use std::{
    collections::HashSet,
    fmt, fs,
    io::Error as IoError,
    path::{Path, PathBuf},
    sync::OnceLock,
    time::Duration,
};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::process::Command as TokioCommand;
use tokio::sync::Mutex;

#[derive(Debug)]
pub enum FileError {
    GlobPattern(PatternError),
    GlobExpansion(GlobError),
    Io(IoError),
}

#[derive(Debug)]
pub enum CommandError {
    Io(IoError),
    Timeout,
}

impl fmt::Display for FileError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            FileError::GlobPattern(e) => write!(f, "Invalid glob pattern: {}", e),
            FileError::GlobExpansion(e) => write!(f, "Failed to expand glob: {}", e),
            FileError::Io(e) => write!(f, "IO error: {}", e),
        }
    }
}

impl std::error::Error for FileError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            FileError::GlobPattern(e) => Some(e),
            FileError::GlobExpansion(e) => Some(e),
            FileError::Io(e) => Some(e),
        }
    }
}

impl From<PatternError> for FileError {
    fn from(err: PatternError) -> Self {
        FileError::GlobPattern(err)
    }
}

impl From<GlobError> for FileError {
    fn from(err: GlobError) -> Self {
        FileError::GlobExpansion(err)
    }
}

impl From<IoError> for FileError {
    fn from(err: IoError) -> Self {
        FileError::Io(err)
    }
}

impl fmt::Display for CommandError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            CommandError::Io(e) => write!(f, "Command execution error: {}", e),
            CommandError::Timeout => write!(f, "Command timed out"),
        }
    }
}

impl std::error::Error for CommandError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            CommandError::Io(e) => Some(e),
            CommandError::Timeout => None,
        }
    }
}

pub fn parse_timeout(timeout_str: Option<&str>, default_timeout: Option<&str>) -> Option<Duration> {
    let timeout_to_parse = timeout_str.or(default_timeout)?;

    if timeout_to_parse == "0" || timeout_to_parse.is_empty() {
        return None;
    }

    match timeout_to_parse.parse::<humantime::Duration>() {
        Ok(duration) => Some(duration.into()),
        Err(e) => {
            eprintln!(
                "Warning: Invalid timeout format '{}': {}",
                timeout_to_parse, e
            );
            eprintln!("Use duration format like '5m', '30s', '1h30m'");
            None
        }
    }
}

pub fn expand_globs(paths: &[PathBuf]) -> Result<Vec<PathBuf>, FileError> {
    let mut result = Vec::new();
    let mut seen = HashSet::new();

    for path in paths {
        let path_str = path.to_string_lossy();

        if is_glob_pattern(&path_str) {
            let expanded_paths = expand_single_glob(&path_str)?;
            for expanded_path in expanded_paths {
                if expanded_path.is_file() && seen.insert(expanded_path.clone()) {
                    result.push(expanded_path);
                }
            }
        } else {
            add_if_exists(path, &mut result, &mut seen);
        }
    }

    Ok(result)
}

fn is_glob_pattern(path: &str) -> bool {
    path.contains('*') || path.contains('?') || path.contains('[')
}

fn expand_single_glob(pattern: &str) -> Result<Vec<PathBuf>, FileError> {
    let glob_paths = glob(pattern)?;
    glob_paths
        .collect::<Result<Vec<_>, _>>()
        .map_err(FileError::from)
}

fn add_if_exists(path: &Path, result: &mut Vec<PathBuf>, seen: &mut HashSet<PathBuf>) {
    if path.exists() && seen.insert(path.to_path_buf()) {
        result.push(path.to_path_buf());
    } else if !path.exists() {
        eprintln!("Warning: Input file '{}' does not exist", path.display());
    }
}

pub fn hash_files(inputs: Vec<PathBuf>) -> Result<Hash, FileError> {
    let expanded_files = expand_globs(&inputs)?;

    if expanded_files.is_empty() {
        return Ok(blake3::hash(b""));
    }

    let mut sorted_files = expanded_files;
    sorted_files.sort();

    let mut hashes = Vec::new();

    for file_path in &sorted_files {
        match fs::read(file_path) {
            Ok(contents) => {
                let path_str = file_path.to_string_lossy();
                let combined = format!("{}:{}", path_str.len(), path_str);
                let mut combined_bytes = combined.into_bytes();
                combined_bytes.extend_from_slice(&contents);

                hashes.push(blake3::hash(&combined_bytes));
            }
            Err(e) => {
                eprintln!(
                    "Warning: Could not read file '{}': {}",
                    file_path.display(),
                    e
                );
            }
        }
    }

    if hashes.is_empty() {
        return Ok(blake3::hash(b""));
    }

    let mut combined_hash_data = Vec::new();
    for hash in &hashes {
        combined_hash_data.extend_from_slice(hash.as_bytes());
    }

    Ok(blake3::hash(&combined_hash_data))
}

pub async fn run_command_with_timeout(
    command: &str,
    timeout: Option<Duration>,
    stream_output: bool,
) -> Result<std::process::Output, CommandError> {
    let mut cmd = if cfg!(target_os = "windows") {
        let mut c = TokioCommand::new("cmd");
        c.args(["/C", command]);
        c
    } else {
        let mut c = TokioCommand::new("sh");
        c.args(["-c", command]);
        c
    };

    cmd.stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .stdin(Stdio::null());

    let mut child = cmd.spawn().map_err(CommandError::Io)?;

    let mut stdout_pipe = child.stdout.take();
    let mut stderr_pipe = child.stderr.take();

    let stdout_handle = tokio::spawn(async move {
        let mut collected: Vec<u8> = Vec::new();
        if let Some(mut pipe) = stdout_pipe.take() {
            let mut out = tokio::io::stdout();
            let mut buf = [0u8; 8192];
            loop {
                let n = pipe.read(&mut buf).await.map_err(CommandError::Io)?;
                if n == 0 {
                    break;
                }
                collected.extend_from_slice(&buf[..n]);
                if stream_output {
                    out.write_all(&buf[..n]).await.map_err(CommandError::Io)?;
                }
            }
            if stream_output {
                out.flush().await.map_err(CommandError::Io)?;
            }
        }
        Ok::<Vec<u8>, CommandError>(collected)
    });

    let stderr_handle = tokio::spawn(async move {
        let mut collected: Vec<u8> = Vec::new();
        if let Some(mut pipe) = stderr_pipe.take() {
            let mut err = tokio::io::stderr();
            let mut buf = [0u8; 8192];
            loop {
                let n = pipe.read(&mut buf).await.map_err(CommandError::Io)?;
                if n == 0 {
                    break;
                }
                collected.extend_from_slice(&buf[..n]);
                if stream_output {
                    err.write_all(&buf[..n]).await.map_err(CommandError::Io)?;
                }
            }
            if stream_output {
                err.flush().await.map_err(CommandError::Io)?;
            }
        }
        Ok::<Vec<u8>, CommandError>(collected)
    });

    let status = match timeout {
        Some(duration) => {
            tokio::select! {
                result = child.wait() => result.map_err(CommandError::Io)?,
                _ = tokio::time::sleep(duration) => {
                    if let Err(kill_err) = child.kill().await {
                        eprintln!("Warning: Failed to kill timed-out process: {}", kill_err);
                    }
                    let _ = child.wait().await;
                    return Err(CommandError::Timeout);
                }
            }
        }
        None => child.wait().await.map_err(CommandError::Io)?,
    };

    let stdout = match stdout_handle.await {
        Ok(Ok(bytes)) => bytes,
        Ok(Err(e)) => return Err(e),
        Err(e) => return Err(CommandError::Io(IoError::other(e))),
    };

    let stderr = match stderr_handle.await {
        Ok(Ok(bytes)) => bytes,
        Ok(Err(e)) => return Err(e),
        Err(e) => return Err(CommandError::Io(IoError::other(e))),
    };

    Ok(Output {
        status,
        stdout,
        stderr,
    })
}

static OUTPUT_PRINT_LOCK: OnceLock<Mutex<()>> = OnceLock::new();

pub fn output_print_lock() -> &'static Mutex<()> {
    OUTPUT_PRINT_LOCK.get_or_init(|| Mutex::new(()))
}

pub fn cleanup_outputs(outputs: &[PathBuf], verbose: bool) -> Result<(), FileError> {
    if outputs.is_empty() {
        return Ok(());
    }

    let expanded_outputs = expand_globs(outputs)?;

    for output_path in &expanded_outputs {
        if output_path.exists() {
            let result = if output_path.is_dir() {
                fs::remove_dir_all(output_path)
            } else {
                fs::remove_file(output_path)
            };

            match result {
                Ok(()) => {
                    if verbose {
                        println!("Removed: {}", output_path.display());
                    }
                }
                Err(e) => {
                    eprintln!(
                        "Warning: Failed to remove '{}': {}",
                        output_path.display(),
                        e
                    );
                }
            }
        }
    }

    Ok(())
}