oxo-flow-cli 0.23.2

CLI for the oxo-flow bioinformatics pipeline engine
//! Shared helpers and utilities for CLI commands.

use anyhow::{Context, Result};
use colored::Colorize;
use std::path::{Path, PathBuf};

/// Auto-discover workflow file in current directory.
/// Priority: main.oxoflow > *.oxoflow (alphabetically first)
pub fn discover_workflow_file() -> Result<PathBuf> {
    let cwd = std::env::current_dir().context("cannot determine current directory")?;
    discover_workflow_file_in(&cwd)
}

/// Auto-discover a workflow file in a specific directory (used by `pull`
/// after cloning a repository).
/// Priority: main.oxoflow > *.oxoflow (alphabetically first)
pub fn discover_workflow_file_in(dir: &Path) -> Result<PathBuf> {
    // Priority 1: main.oxoflow
    let main_workflow = dir.join("main.oxoflow");
    if main_workflow.exists() {
        return Ok(main_workflow);
    }

    // Priority 2: any *.oxoflow file (alphabetically first)
    let mut oxoflow_files: Vec<PathBuf> = Vec::new();
    for entry in std::fs::read_dir(dir)
        .with_context(|| format!("cannot read directory {}", dir.display()))?
    {
        let entry = entry.context("cannot read directory entry")?;
        let path = entry.path();
        if let Some(ext) = path.extension()
            && ext == "oxoflow"
        {
            oxoflow_files.push(path);
        }
    }

    if oxoflow_files.is_empty() {
        return Err(anyhow::anyhow!(
            "no .oxoflow file found in {}.\n\
            The repository must contain a workflow file (main.oxoflow or any *.oxoflow).\n\
            Create one with `oxo-flow init <name>` or `oxo-flow template <description> -o <file>`.",
            dir.display()
        ));
    }

    // Sort alphabetically and pick first
    oxoflow_files.sort();
    Ok(oxoflow_files.into_iter().next().unwrap())
}

/// Resolve workflow path: use provided path or auto-discover.
pub fn resolve_workflow(provided: Option<PathBuf>) -> Result<PathBuf> {
    match provided {
        Some(path) => Ok(path),
        None => discover_workflow_file(),
    }
}

/// Global quiet mode flag — set by main.rs when --quiet is passed.
static QUIET_MODE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);

pub fn set_quiet_mode(quiet: bool) {
    QUIET_MODE.store(quiet, std::sync::atomic::Ordering::Relaxed);
}

/// Whether `--quiet` is active (suppresses non-essential console output).
pub fn is_quiet() -> bool {
    QUIET_MODE.load(std::sync::atomic::Ordering::Relaxed)
}

pub fn print_banner() {
    if QUIET_MODE.load(std::sync::atomic::Ordering::Relaxed) {
        return;
    }
    // The banner is decoration for humans at an interactive terminal. When
    // stderr is redirected (nohup, `> log 2>&1`, CI, schedulers) it becomes
    // noise on every invocation and pollutes captured logs (issue #433);
    // version provenance stays available via the run-log header and --version.
    if !std::io::IsTerminal::is_terminal(&std::io::stderr()) {
        return;
    }
    eprintln!(
        "{} v{} — {}",
        "oxo-flow".bold().cyan(),
        env!("CARGO_PKG_VERSION"),
        "Rust-native bioinformatics pipeline engine".dimmed()
    );
    eprintln!("{}", env!("CARGO_PKG_REPOSITORY").cyan());
}

/// Expand placeholders in a command template.
pub fn expand_batch_template(template: &str, item: &str, nr: usize) -> String {
    let path = std::path::Path::new(item);
    let basename = path
        .file_name()
        .map(|n| n.to_string_lossy())
        .unwrap_or_default();
    let stem = path
        .file_stem()
        .map(|s| s.to_string_lossy())
        .unwrap_or_default();
    let ext = path
        .extension()
        .map(|e| e.to_string_lossy())
        .unwrap_or_default();
    let dir = oxo_flow_core::parent_dir(path).to_string_lossy();

    template
        .replace("{}", item)
        .replace("{item}", item)
        .replace("{nr}", &nr.to_string())
        .replace("{basename}", &basename)
        .replace("{stem}", &stem)
        .replace("{ext}", &ext)
        .replace("{dir}", &dir)
}

/// Parse item lines, skipping blank lines and # comments.
pub fn parse_item_lines(content: &str) -> Vec<String> {
    content
        .lines()
        .filter(|line| {
            let trimmed = line.trim();
            !trimmed.is_empty() && !trimmed.starts_with('#')
        })
        .map(|line| line.trim().to_string())
        .collect()
}

/// Collect items from arguments, file, or stdin.
pub fn collect_batch_items(items: &[String], file: Option<&PathBuf>) -> Result<Vec<String>> {
    // Priority: file > stdin > arguments
    if let Some(path) = file {
        let content = std::fs::read_to_string(path)
            .with_context(|| format!("failed to read items from {}", path.display()))?;
        return Ok(parse_item_lines(&content));
    }

    // Check stdin if no items provided
    if items.is_empty() {
        use std::io::{self, BufRead};
        let stdin = io::stdin();
        // A non-UTF-8 byte in piped input must ERROR, not silently
        // truncate the item list to the prefix before it (audit #671) —
        // batch then ran on fewer files than piped, all succeeding, exit 0.
        let lines: Vec<String> = stdin
            .lock()
            .lines()
            .collect::<io::Result<Vec<_>>>()
            .map_err(|e| anyhow::anyhow!("reading batch items from stdin failed: {e}"))?;
        if !lines.is_empty() {
            return Ok(parse_item_lines(&lines.join("\n")));
        }
        return Err(anyhow::anyhow!(
            "no items provided (use -f FILE, stdin, or arguments)"
        ));
    }

    // Expand globs in arguments
    let expanded: Vec<String> = items
        .iter()
        .flat_map(|item| {
            if item.contains('*') || item.contains('?') || item.contains('[') {
                glob::glob(item)
                    .ok()
                    .into_iter()
                    .flatten()
                    .filter_map(|p| p.ok())
                    .map(|p| p.to_string_lossy().to_string())
                    .collect::<Vec<_>>()
            } else {
                vec![item.clone()]
            }
        })
        .collect();

    Ok(expanded)
}

/// Quote a string as a single POSIX shell word, escaping embedded single
/// quotes (`'` → `'\''`).
fn shell_quote_word(s: &str) -> String {
    format!("'{}'", s.replace('\'', "'\\''"))
}

/// Compute SHA-256 checksum of a file (streaming, 64KB buffer).
pub(crate) fn compute_sha256(path: &Path) -> Result<String> {
    use sha2::{Digest, Sha256};
    use std::io::Read;

    // Stat-first regular-file gate (issue #714, pattern from #695/#706):
    // File::open on a writer-less FIFO blocks forever.
    let meta = std::fs::metadata(path)
        .with_context(|| format!("failed to stat for checksum: {}", path.display()))?;
    if !meta.is_file() {
        anyhow::bail!("cannot checksum {}: not a regular file", path.display());
    }

    let file = std::fs::File::open(path)
        .with_context(|| format!("failed to open for checksum: {}", path.display()))?;
    let mut reader = std::io::BufReader::with_capacity(65536, file);
    let mut hasher = Sha256::new();
    let mut buf = [0u8; 65536];
    loop {
        let n = reader.read(&mut buf)?;
        if n == 0 {
            break;
        }
        hasher.update(&buf[..n]);
    }
    Ok(format!("sha256:{}", hex::encode(hasher.finalize())))
}

/// Wrap command with environment activation.
pub fn wrap_batch_command(cmd: &str, env_spec: &str) -> String {
    // Parse environment spec: "conda: env.yaml" or "docker: image"
    if let Some((type_, spec)) = env_spec.split_once(':') {
        match type_.trim() {
            "conda" => {
                let env_name = std::path::Path::new(spec.trim())
                    .file_stem()
                    .and_then(|s| s.to_str())
                    .unwrap_or(spec.trim());
                format!("conda run --no-banner -n {} {}", env_name, cmd)
            }
            "docker" | "singularity" => {
                // Each whitespace-separated token of the spec is passed as one
                // quoted argument so characters like `;` or `$(` in the TOML
                // cannot break out of the wrapper into the surrounding shell.
                let spec_args = spec
                    .split_whitespace()
                    .map(shell_quote_word)
                    .collect::<Vec<_>>()
                    .join(" ");
                // The command itself is embedded inside a single-quoted `sh -c`
                // script; escape quotes so commands containing `'` (e.g. awk
                // programs) survive and cannot terminate the quoting early.
                let quoted_cmd = shell_quote_word(cmd);
                match type_.trim() {
                    "docker" => format!("docker run --rm {spec_args} sh -c {quoted_cmd}"),
                    _ => format!("singularity exec {spec_args} sh -c {quoted_cmd}"),
                }
            }
            _ => cmd.to_string(),
        }
    } else {
        // Assume conda environment name
        format!("conda run --no-banner -n {} {}", env_spec.trim(), cmd)
    }
}

/// Run a single batch command synchronously.
pub fn run_batch_command(cmd: &str, workdir: &Path) -> Result<i32> {
    use std::process::Command;

    let output = Command::new("sh")
        .arg("-c")
        .arg(cmd)
        .current_dir(workdir)
        .output()
        .with_context(|| format!("failed to execute: {}", cmd))?;

    Ok(output.status.code().unwrap_or(-1))
}

pub mod ai_check;
pub mod ai_explain;
pub mod ai_recover;
pub mod ai_runtime;
pub mod ai_session;
pub mod ai_status;
pub mod ai_template;
pub mod batch;
pub mod bundle;
pub mod clean;
pub mod cluster;
pub mod completions;
pub mod config_comments;
pub mod info;
pub mod infra;
pub mod output;
pub mod project;
pub mod provenance;
pub mod publish;
pub mod pull;
pub mod quality;
pub mod run;
pub mod run_cluster;
pub mod run_preview;
pub mod samples;
pub mod web;
pub mod workdir_sources;

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

    #[test]
    fn docker_wrapper_escapes_single_quotes() {
        let wrapped = wrap_batch_command("awk '{print $1}'", "docker: biocontainers/samtools");
        assert!(wrapped.starts_with("docker run --rm 'biocontainers/samtools' sh -c "));
        // The command must survive as one quoted argument.
        assert!(wrapped.ends_with(r#"'awk '\''{print $1}'\'''"#));
        assert_eq!(wrapped.matches("sh -c").count(), 1);
    }

    #[test]
    fn singularity_wrapper_escapes_injection() {
        let wrapped = wrap_batch_command("echo ok'; rm -rf /tmp/x", "singularity: image.sif");
        // Exact wrapping: the injected quote is escaped (`'\''`) and the
        // trailing `; rm ...` stays inside the still-open quote, so the inner
        // `sh -c` receives it as part of the container's command string rather
        // than as outer-shell syntax.
        assert_eq!(
            wrapped,
            r#"singularity exec 'image.sif' sh -c 'echo ok'\''; rm -rf /tmp/x'"#
        );
    }

    #[test]
    fn docker_spec_tokens_are_individually_quoted() {
        let wrapped = wrap_batch_command("true", "docker: -v /a:/b --gpus all");
        // Every token quoted separately keeps multi-arg semantics while
        // neutralizing metacharacters inside tokens.
        assert!(wrapped.contains("'-v' '/a:/b' '--gpus' 'all'"));
    }

    #[test]
    fn conda_wrapper_unchanged() {
        let wrapped = wrap_batch_command("python x.py", "conda: envs/tools.yaml");
        assert_eq!(wrapped, "conda run --no-banner -n tools python x.py");
    }
}