oxo-flow-cli 0.14.1

CLI for the oxo-flow bioinformatics pipeline engine
//! Bundle consumption — extract, verify, and execute published bundles.
//!
//! A bundle is a compressed tar archive produced by `oxo-flow publish` containing
//! a workflow file, environment specs, scripts, and a checksum-verified manifest.
//!
//! Supported formats: `.tar.zst` (default), `.tar.gz`.

use anyhow::{Context, Result};
use colored::Colorize;
use sha2::{Digest, Sha256};
use std::io::Read;
use std::path::{Path, PathBuf};

/// Supported bundle archive formats.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BundleFormat {
    TarZst,
    TarGz,
}

impl BundleFormat {
    pub fn extension(&self) -> &str {
        match self {
            Self::TarZst => "tar.zst",
            Self::TarGz => "tar.gz",
        }
    }

    pub fn from_path(path: &Path) -> Option<Self> {
        let s = path.to_string_lossy();
        if s.ends_with(".tar.zst") {
            Some(Self::TarZst)
        } else if s.ends_with(".tar.gz") || s.ends_with(".tgz") {
            Some(Self::TarGz)
        } else {
            None
        }
    }

    pub fn parse(s: &str) -> Result<Self> {
        match s {
            "tar.zst" | "zst" => Ok(Self::TarZst),
            "tar.gz" | "gz" | "tgz" => Ok(Self::TarGz),
            _ => anyhow::bail!("unsupported format '{}'. Use 'tar.zst' or 'tar.gz'", s),
        }
    }
}

/// Extract a bundle, verify all file checksums against the manifest,
/// and return `(workflow_path, extraction_dir)`. Auto-detects format
/// from file extension.
pub fn extract_and_verify_bundle(bundle_path: &Path) -> Result<(PathBuf, PathBuf)> {
    let bundle_abs = std::path::absolute(bundle_path)
        .with_context(|| format!("failed to resolve bundle path: {}", bundle_path.display()))?;

    // Extract to a freshly created temp directory.
    //
    // The previous `temp_dir()/oxo-bundle-<pid>` path was predictable and created
    // with `create_dir_all`, which succeeds against a directory that already
    // exists — on a shared machine another user can pre-create it, and PIDs are
    // reused. `tempdir()` creates a uniquely named directory with 0700
    // permissions, failing if it cannot create it exclusively.
    //
    // The directory becomes the working directory for the run and holds the
    // workflow's output files, so it is `.keep()`-ed on success only —
    // every failure path below drops the TempDir and cleans up automatically.
    let extract_dir = tempfile::Builder::new()
        .prefix("oxo-bundle-")
        .tempdir()
        .context("failed to create temporary directory for bundle extraction")?;

    // Auto-detect format from extension and open archive
    let format = BundleFormat::from_path(&bundle_abs).unwrap_or(BundleFormat::TarZst); // default fallback
    let file = std::fs::File::open(&bundle_abs)
        .with_context(|| format!("failed to open bundle: {}", bundle_abs.display()))?;
    let reader: Box<dyn Read> = match format {
        BundleFormat::TarZst => Box::new(
            zstd::stream::read::Decoder::new(file).context("failed to decompress bundle (zstd)")?,
        ),
        BundleFormat::TarGz => Box::new(flate2::read::GzDecoder::new(file)),
    };
    let mut archive = tar::Archive::new(reader);

    // Extract all files
    archive
        .unpack(extract_dir.path())
        .context("failed to extract bundle")?;

    // Read and verify manifest
    let manifest_path = extract_dir.path().join("manifest.json");
    let manifest_json = std::fs::read_to_string(&manifest_path)
        .context("bundle is missing manifest.json — not a valid oxo-flow bundle")?;
    let manifest: serde_json::Value =
        serde_json::from_str(&manifest_json).context("failed to parse manifest.json")?;

    let format = manifest["format"].as_str().unwrap_or("unknown");
    if format != "oxoflow-bundle-v1" {
        anyhow::bail!(
            "unsupported bundle format '{}' — expected 'oxoflow-bundle-v1'",
            format
        );
    }

    let entrypoint = manifest["entrypoint"]
        .as_str()
        .context("manifest missing 'entrypoint' field")?;
    let workflow_path = extract_dir.path().join(entrypoint);
    if !workflow_path.exists() {
        anyhow::bail!(
            "bundle entrypoint '{}' not found in archive",
            workflow_path.display()
        );
    }

    // Display resource requirements from manifest (if present)
    if let Some(resources) = manifest.get("resources") {
        if let Some(recommendations) = resources.get("recommendations") {
            eprintln!("{}", "Bundle resource requirements:".bold().underline());
            if let Some(t) = recommendations["min_threads"].as_u64() {
                eprintln!("  Min threads: {}", t.to_string().cyan());
            }
            if let Some(m) = recommendations["min_memory_mb"].as_u64() {
                let gb = m as f64 / 1024.0;
                eprintln!("  Min memory:  {} ({:.1} GB)", m.to_string().cyan(), gb);
            }
            if let Some(g) = recommendations["min_gpu"].as_u64()
                && g > 0
            {
                eprintln!("  Min GPU:     {}", g.to_string().cyan());
            }
        }
        if let Some(rules) = resources.get("rules").and_then(|r| r.as_array()) {
            eprintln!(
                "  {} rules with resource declarations",
                rules.len().to_string().cyan()
            );
        }
        eprintln!();
    }

    // Verify checksums
    let files = manifest["files"]
        .as_array()
        .context("manifest missing 'files' array")?;
    let mut verified = 0usize;
    for file_entry in files {
        let path = file_entry["path"]
            .as_str()
            .context("file entry missing 'path'")?;
        let expected_sha = file_entry["sha256"]
            .as_str()
            .context("file entry missing 'sha256'")?;

        let file_path = extract_dir.path().join(path);
        if !file_path.exists() {
            anyhow::bail!(
                "file '{}' declared in manifest but missing from archive",
                path
            );
        }

        let actual_sha = compute_sha256(&file_path)?;
        if actual_sha != expected_sha {
            // The TempDir drop cleans up the extraction on this bail.
            anyhow::bail!(
                "checksum mismatch for '{}':\n  expected: {}\n  actual:   {}\nBundle verification failed.",
                path,
                expected_sha,
                actual_sha
            );
        }
        verified += 1;
    }

    eprintln!(
        "{} Bundle verified: {}/{} files OK",
        "".green(),
        verified,
        files.len()
    );

    // Verified successfully — hand ownership of the directory to the run.
    Ok((workflow_path, extract_dir.keep()))
}

/// Compute SHA-256 checksum of a file (streaming, 64KB buffer).
fn compute_sha256(path: &Path) -> Result<String> {
    let file = std::fs::File::open(path)?;
    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:{:x}", hasher.finalize()))
}

/// Find manifest.json in an extracted bundle directory.
pub fn find_manifest_in_dir(dir: &Path) -> Result<PathBuf> {
    let manifest_path = dir.join("manifest.json");
    if manifest_path.exists() {
        Ok(manifest_path)
    } else {
        anyhow::bail!(
            "manifest.json not found in extracted bundle: {}",
            dir.display()
        )
    }
}

/// Whether the bundle confirmation prompt can be shown and answered.
///
/// Writing the prompt needs a terminal on stderr, and reading the answer needs
/// an interactive stdin — a redirected stdin reaches `read_line`, hits EOF, and
/// looks like a cancellation the user never made. `--json` marks the invocation
/// as machine-driven, so it is never interactive regardless of the terminal.
pub fn can_prompt_for_confirmation(json: bool, stderr_is_tty: bool, stdin_is_tty: bool) -> bool {
    !json && stderr_is_tty && stdin_is_tty
}

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

    #[test]
    fn prompts_only_on_a_fully_interactive_terminal() {
        assert!(can_prompt_for_confirmation(false, true, true));
    }

    #[test]
    fn redirected_stdin_is_not_promptable() {
        // `oxo-flow run --bundle b.tar.zst < /dev/null` from a terminal: stderr
        // is still a tty, but there is nobody to answer.
        assert!(!can_prompt_for_confirmation(false, true, false));
    }

    #[test]
    fn piped_stderr_is_not_promptable() {
        assert!(!can_prompt_for_confirmation(false, false, true));
    }

    #[test]
    fn json_is_never_promptable() {
        assert!(!can_prompt_for_confirmation(true, true, true));
    }
}