use anyhow::{Context, Result};
use colored::Colorize;
use std::path::{Path, PathBuf};
pub fn discover_workflow_file() -> Result<PathBuf> {
let cwd = std::env::current_dir().context("cannot determine current directory")?;
discover_workflow_file_in(&cwd)
}
pub fn discover_workflow_file_in(dir: &Path) -> Result<PathBuf> {
let main_workflow = dir.join("main.oxoflow");
if main_workflow.exists() {
return Ok(main_workflow);
}
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()
));
}
oxoflow_files.sort();
Ok(oxoflow_files.into_iter().next().unwrap())
}
pub fn resolve_workflow(provided: Option<PathBuf>) -> Result<PathBuf> {
match provided {
Some(path) => Ok(path),
None => discover_workflow_file(),
}
}
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);
}
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;
}
eprintln!(
"{} v{} — {}",
"oxo-flow".bold().cyan(),
env!("CARGO_PKG_VERSION"),
"Rust-native bioinformatics pipeline engine".dimmed()
);
eprintln!("{}", env!("CARGO_PKG_REPOSITORY").cyan());
}
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)
}
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()
}
pub fn collect_batch_items(items: &[String], file: Option<&PathBuf>) -> Result<Vec<String>> {
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));
}
if items.is_empty() {
use std::io::{self, BufRead};
let stdin = io::stdin();
let lines: Vec<String> = stdin.lock().lines().map_while(Result::ok).collect();
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)"
));
}
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)
}
fn shell_quote_word(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
pub(crate) fn compute_sha256(path: &Path) -> Result<String> {
use sha2::{Digest, Sha256};
use std::io::Read;
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())))
}
pub fn wrap_batch_command(cmd: &str, env_spec: &str) -> String {
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" => {
let spec_args = spec
.split_whitespace()
.map(shell_quote_word)
.collect::<Vec<_>>()
.join(" ");
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 {
format!("conda run --no-banner -n {} {}", env_spec.trim(), cmd)
}
}
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;
#[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 "));
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");
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");
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");
}
}