use anyhow::{Context, Result};
use colored::Colorize;
use oxo_flow_core::config::WorkflowConfig;
use crate::commands::print_banner;
use crate::{ConfigAction, EnvAction};
fn resolved_rule_environment(
config: &WorkflowConfig,
rule: &oxo_flow_core::rule::Rule,
) -> oxo_flow_core::rule::EnvironmentSpec {
config.resolve_environment(rule).unwrap_or_default()
}
pub async fn env_command(action: EnvAction) -> Result<()> {
print_banner();
match action {
EnvAction::List { workflow } => match workflow {
Some(wf_path) => {
let config = WorkflowConfig::from_file(&wf_path)
.with_context(|| format!("failed to parse {}", wf_path.display()))?;
eprintln!(
"{} {}",
"Environments in".bold(),
wf_path.display().to_string().dimmed()
);
let mut seen = std::collections::HashSet::new();
for rule in &config.rules {
let env = resolved_rule_environment(&config, rule);
let kind = env.kind().to_string();
if seen.insert(format!("{}:{kind}", rule.name)) {
eprintln!(" {} {} [{}]", "✓".green(), rule.name, kind);
}
}
if seen.is_empty() {
eprintln!(
" (no environment specifications found — rules will use system environment)"
);
}
}
None => {
let resolver = oxo_flow_core::environment::EnvironmentResolver::new();
let available = resolver.available_backends();
eprintln!("{}", "Available environment backends:".bold());
for backend in available {
eprintln!(" {} {}", "✓".green(), backend);
}
}
},
EnvAction::Check { workflow } => {
let resolver = oxo_flow_core::environment::EnvironmentResolver::new();
match workflow {
Some(wf_path) => {
let config = WorkflowConfig::from_file(&wf_path)
.with_context(|| format!("failed to parse {}", wf_path.display()))?;
let workflow_dir = wf_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| std::path::Path::new("."));
let mut all_ok = true;
for rule in &config.rules {
let resolved = resolved_rule_environment(&config, rule);
let spec = resolved.resolve_relative_paths(Some(workflow_dir));
match resolver.validate_spec(&spec) {
Ok(()) => {
eprintln!(" {} {} ({})", "✓".green(), rule.name, resolved.kind());
}
Err(e) => {
eprintln!(" {} {} — {}", "✗".red(), rule.name, e);
all_ok = false;
}
}
}
if !all_ok {
std::process::exit(1);
}
}
None => {
eprintln!("{}", "Environment backend availability:".bold());
let available = resolver.available_backends();
for backend in
oxo_flow_core::environment::EnvironmentResolver::all_known_backends()
{
if available.contains(backend) {
match resolver.resolved_backend_binary(backend) {
Some(bin) if bin != *backend => {
eprintln!(" {} {} (resolved: {})", "✓".green(), backend, bin);
}
_ => {
eprintln!(" {} {}", "✓".green(), backend);
}
}
} else {
eprintln!(" {} {} (not found)", "✗".red(), backend);
}
}
}
}
}
EnvAction::Create {
spec,
name,
ai,
backend,
} => {
if ai {
return create_env_from_ai(&spec, name, backend.as_deref().unwrap_or("conda"))
.await;
}
let name_str = name.clone().unwrap_or_else(|| {
spec.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string()
});
let ext = spec.extension().and_then(|e| e.to_str()).unwrap_or("");
let ext_backend = match ext {
"yaml" | "yml" => "conda",
"toml" => "pixi",
"lock" => "conda",
_ => {
eprintln!(
"{} Unknown environment spec format: '{}'",
"Warning:".yellow(),
spec.display()
);
eprintln!(
" Supported formats: .yaml/.yml (conda), .toml (pixi), .lock (conda-lock)"
);
anyhow::bail!("Unsupported environment spec format");
}
};
let backend = match backend {
Some(requested) => {
if !requested.eq_ignore_ascii_case(ext_backend) {
eprintln!(
"{} --backend {requested} overrides the spec format \
('.{ext}' is a {ext_backend} spec)",
"Warning:".yellow()
);
}
requested
}
None => ext_backend.to_string(),
};
eprintln!(
"{} Creating {} environment '{}' from '{}'...",
"Info:".bold().cyan(),
backend,
name_str,
spec.display()
);
match backend.as_str() {
"conda" => {
let (tool, status) = {
let spec = spec.clone();
let name_str = name_str.clone();
tokio::task::spawn_blocking(move || {
let mamba_exists = std::process::Command::new("mamba")
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok();
let micromamba_exists = std::process::Command::new("micromamba")
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok();
let tool = if mamba_exists {
"mamba"
} else if micromamba_exists {
"micromamba"
} else {
"conda"
};
let status = std::process::Command::new(tool)
.args([
"env",
"create",
"-f",
&spec.to_string_lossy(),
"-n",
&name_str,
])
.status()
.with_context(|| format!("failed to run {tool} env create"))?;
anyhow::Ok((tool.to_string(), status))
})
.await
.context("env create task failed")??
};
if !status.success() {
anyhow::bail!(
"{} env create failed with exit code {:?}",
tool,
status.code()
);
}
eprintln!(
" {} Environment '{}' created successfully.",
"✓".green(),
name_str
);
eprintln!(" Activate with: conda activate {}", name_str);
}
"pixi" => {
let pixi_toml = std::fs::read_to_string(&spec)
.with_context(|| format!("cannot read pixi spec: {}", spec.display()))?;
let temp_project =
tempfile::Builder::new()
.prefix("oxo-flow-env-")
.tempdir()
.context("failed to create temporary pixi project directory")?;
let project_dir = temp_project.path().to_path_buf();
std::fs::write(project_dir.join("pixi.toml"), &pixi_toml)
.with_context(|| "failed to write pixi.toml")?;
let status = {
let project_dir = project_dir.clone();
tokio::task::spawn_blocking(move || {
std::process::Command::new("pixi")
.args(["install"])
.current_dir(&project_dir)
.status()
.with_context(|| "failed to run pixi install")
})
.await
.context("pixi install task failed")?
}?;
if !status.success() {
anyhow::bail!("pixi install failed");
}
let _ = temp_project.keep();
eprintln!(
" {} Pixi project created at: {}",
"✓".green(),
project_dir.display()
);
eprintln!(
" Activate with: cd {} && pixi shell",
project_dir.display()
);
}
other => {
anyhow::bail!(
"Environment backend '{}' not supported for env create yet",
other
);
}
}
}
EnvAction::Gc {
min_age_days,
dry_run,
} => env_gc(min_age_days, dry_run)?,
}
Ok(())
}
#[cfg(test)]
mod env_resolution_tests {
use super::*;
#[test]
fn env_resolution_matches_run_defaults_and_overrides() {
let config = WorkflowConfig::parse(
r#"
[workflow]
name = "env-parity"
[defaults.environment]
conda = "envs/default.yaml"
[env_groups.grouped]
docker = "ubuntu:22.04"
[[rules]]
name = "defaulted"
shell = "echo hi"
[[rules]]
name = "override"
[rules.environment]
venv = ".venv"
[[rules]]
name = "grouped"
env_group = "grouped"
shell = "echo hi"
"#,
)
.expect("workflow parses");
let by_name = |name: &str| {
let rule = config.get_rule(name).expect("rule exists");
resolved_rule_environment(&config, rule).kind().to_string()
};
assert_eq!(by_name("defaulted"), "conda");
assert_eq!(by_name("override"), "venv");
assert_eq!(by_name("grouped"), "docker");
}
#[test]
fn env_resolution_without_declarations_stays_system() {
let config = WorkflowConfig::parse(
r#"
[workflow]
name = "plain"
[[rules]]
name = "plain_rule"
shell = "echo hi"
"#,
)
.expect("workflow parses");
let rule = config.get_rule("plain_rule").expect("rule exists");
assert_eq!(resolved_rule_environment(&config, rule).kind(), "system");
}
}
fn env_gc(min_age_days: u64, dry_run: bool) -> Result<()> {
let store = oxo_flow_core::executor::env_store::EnvStore::from_env().unwrap_or_else(|| {
oxo_flow_core::executor::env_store::EnvStore::from_root(std::path::PathBuf::new())
});
if !store.is_enabled() {
eprintln!(
"{} OXO_FLOW_ENV_STORE is not set — the shared env store is disabled \
(envs are self-contained inside each workflow tree, nothing to collect).",
"Note:".yellow()
);
return Ok(());
}
let entries = store
.entries()
.with_context(|| format!("failed to scan env store at {}", store.root().display()))?;
eprintln!(
"{} {} ({} env dirs)",
"Shared environment store:".bold(),
store.root().display().to_string().dimmed(),
entries.len()
);
if entries.is_empty() {
eprintln!(" (nothing to collect)");
return Ok(());
}
let mut referenced = std::collections::BTreeSet::new();
for ck_path in discover_checkpoints() {
match oxo_flow_core::executor::CheckpointState::load_from_file(&ck_path) {
Ok(state) => {
for dir in oxo_flow_core::executor::env_store::referenced_store_dirs([
&state.env_store_refs
]) {
referenced.insert(dir);
}
}
Err(e) => {
eprintln!(
" {} unreadable checkpoint {}: {} (its references are ignored)",
"Warning:".yellow(),
ck_path.display(),
e
);
}
}
}
let cutoff = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
.saturating_sub(min_age_days * 86_400);
let mut total_freed: u64 = 0;
let mut deleted = 0usize;
let mut failed = 0usize;
for entry in &entries {
let name = entry.path.file_name().unwrap_or_default().to_string_lossy();
let eligible =
!referenced.contains(name.as_ref()) && entry.mtime.is_some_and(|mtime| mtime <= cutoff);
let status = if referenced.contains(name.as_ref()) {
"referenced by a checkpoint — kept".to_string()
} else {
match entry.mtime {
Some(mtime) if mtime <= cutoff => {
if entry.ready {
"unreferenced and old enough — eligible".to_string()
} else {
"unreferenced (torn/incomplete) and old enough — eligible".to_string()
}
}
Some(mtime) => {
let days_left = mtime.saturating_sub(cutoff) / 86_400;
let hint = if days_left == 0 {
"eligible next gc run".to_string()
} else {
format!("~{days_left}d until eligible")
};
format!("unreferenced but too new ({hint})")
}
None => "unreferenced but mtime unknown — kept".to_string(),
}
};
let size = format_bytes(entry.size);
eprintln!(" {} [{}] {}", name, entry.kind.prefix(), status);
eprintln!(
" {} {}",
size,
entry.path.display().to_string().dimmed()
);
if eligible {
if !dry_run && let Err(e) = std::fs::remove_dir_all(&entry.path) {
eprintln!(" {} failed to delete: {}", "✗".red(), e);
failed += 1;
continue;
}
deleted += 1;
total_freed += entry.size;
}
}
if !dry_run && failed > 0 {
return Err(anyhow::anyhow!(
"env gc: {} failed to delete",
oxo_flow_core::plural(failed, "store dir", "store dirs")
));
}
if dry_run {
eprintln!(
"\n{} {} store dir(s) would be deleted, freeing ~{} (dry run — nothing touched)",
"Summary:".bold(),
deleted,
format_bytes(total_freed)
);
} else {
eprintln!(
"\n{} {} store dir(s) deleted, ~{} freed",
"Summary:".bold(),
deleted,
format_bytes(total_freed)
);
}
if !dry_run && deleted > 0 {
eprintln!(
" Workflow trees keep their symlinks; a deleted env is re-created on the next run."
);
}
Ok(())
}
fn discover_checkpoints() -> Vec<std::path::PathBuf> {
let mut out = Vec::new();
let ck = std::path::Path::new(".oxo-flow").join("checkpoint.json");
if ck.is_file() {
out.push(ck);
}
out
}
fn format_bytes(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
let mut value = bytes as f64;
let mut unit = 0usize;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes} {}", UNITS[unit])
} else {
format!("{value:.1} {}", UNITS[unit])
}
}
pub fn handle_config(action: ConfigAction) -> Result<()> {
print_banner();
match action {
ConfigAction::Show { workflow } => {
let config = WorkflowConfig::from_file(&workflow)
.with_context(|| format!("failed to parse {}", workflow.display()))?;
eprintln!("{}", "Workflow Configuration:".bold());
eprintln!(" Name: {}", config.workflow.name);
eprintln!(" Version: {}", config.workflow.version);
if let Some(ref desc) = config.workflow.description {
eprintln!(" Desc: {}", desc);
}
if let Some(ref author) = config.workflow.author {
eprintln!(" Author: {}", author);
}
eprintln!("\n{}", "Config Variables:".bold());
if config.config.is_empty() {
eprintln!(" (none)");
} else {
for (k, v) in &config.config {
eprintln!(" {} = {}", k, v);
}
}
}
ConfigAction::Stats { workflow } => {
let config = WorkflowConfig::from_file(&workflow)
.with_context(|| format!("failed to parse {}", workflow.display()))?;
let stats = oxo_flow_core::format::workflow_stats(&config);
eprintln!("{}", "Workflow Statistics:".bold());
eprintln!(" Workflow: {}", config.workflow.name);
eprintln!(" Rules: {}", stats.rule_count);
eprintln!(" Shell rules: {}", stats.shell_rules);
eprintln!(" Script rules: {}", stats.script_rules);
eprintln!(" Dependencies: {}", stats.dependency_count);
eprintln!(" Parallel groups: {}", stats.parallel_groups);
eprintln!(" Max depth: {}", stats.max_depth);
eprintln!(" Total threads: {}", stats.total_threads);
eprintln!(
" Wildcards: {} ({:?})",
stats.wildcard_count, stats.wildcard_names
);
if !stats.environments.is_empty() {
eprintln!(" Environments: {:?}", stats.environments);
}
}
ConfigAction::Get { workflow, key } => {
let config = WorkflowConfig::from_file(&workflow)?;
if let Some(val) = config.config.get(&key) {
println!("{}", val);
} else {
return Err(anyhow::anyhow!("config key '{}' not found", key));
}
}
}
Ok(())
}
pub fn handle_license(path: Option<std::path::PathBuf>, json: bool) -> Result<()> {
let status = oxo_flow_web::check_license();
if let Some(p) = path {
match oxo_license::load_and_verify(Some(&p), &oxo_flow_web::OXO_FLOW_CONFIG) {
Ok(license) => {
if json {
println!(
"{}",
serde_json::json!({
"verified": true,
"type": license.payload.license_type,
"issued_to": license.payload.issued_to_org,
"schema": license.payload.schema,
"id": license.payload.license_id,
})
);
} else {
println!("{} License verified successfully", "✓".green().bold());
println!(" Type: {}", license.payload.license_type);
println!(" Issued: {}", license.payload.issued_to_org);
println!(" Schema: {}", license.payload.schema);
println!(" ID: {}", license.payload.license_id);
}
}
Err(e) => {
if json {
println!(
"{}",
serde_json::json!({ "verified": false, "error": e.to_string() })
);
}
anyhow::bail!("License verification failed: {e}");
}
}
} else if json {
println!(
"{}",
serde_json::json!({
"valid": status.valid,
"license_type": status.license_type,
"issued_to": status.issued_to,
"message": status.message,
})
);
} else {
println!("License status:");
if status.valid {
println!(
" Status: {} ({})",
"Valid".green().bold(),
status.license_type.as_deref().unwrap_or("unknown")
);
if let Some(org) = &status.issued_to {
println!(" Issued: {org}");
}
} else {
println!(" Status: {}", "Invalid".red().bold());
}
println!(" Message: {}", status.message);
}
Ok(())
}
fn match_description_tools(description: &str) -> Vec<(String, String, String)> {
const STOP_WORDS: &[&str] = &[
"and", "the", "for", "with", "using", "into", "from", "your", "pipeline", "workflow",
"analysis", "data", "files", "output", "input", "all", "new", "this", "that", "via",
"then", "also", "based", "need", "want", "please",
];
let mut matched = Vec::new();
let mut seen = std::collections::HashSet::new();
for word in description
.split(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
.filter(|w| w.len() >= 3 && !STOP_WORDS.contains(&w.to_ascii_lowercase().as_str()))
{
let word_lower = word.to_ascii_lowercase();
for tool in oxo_flow_ai::knowledge::bioconda::search_tools(word, 3) {
if !tool.name.to_ascii_lowercase().contains(&word_lower) {
continue;
}
if seen.insert(tool.name.clone()) {
let summary = if tool.summary.is_empty() {
"(no description)".to_string()
} else {
tool.summary.clone()
};
matched.push((tool.name.clone(), tool.version.clone(), summary));
}
}
}
matched
}
async fn create_env_from_ai(
description: &std::path::Path,
name: Option<String>,
backend: &str,
) -> Result<()> {
let description = description.to_string_lossy().to_string();
let provider = crate::commands::ai_template::resolve_ai_provider()?;
let is_pixi = backend.eq_ignore_ascii_case("pixi");
if !is_pixi && !backend.eq_ignore_ascii_case("conda") {
anyhow::bail!("unsupported backend '{}'. Use 'conda' or 'pixi'.", backend);
}
println!("{}", "AI Environment Generator".bold().green());
println!(
" Model: {}",
provider.model().unwrap_or_else(|| "default".into())
);
println!(" Backend: {}", if is_pixi { "pixi" } else { "conda" });
println!(" Description: {description}\n");
let matched = match_description_tools(&description);
if !matched.is_empty() {
eprintln!("{}", " Matched Bioconda tools:".bold().cyan());
for (name, version, summary) in &matched {
eprintln!(" - {name} {version} — {summary}");
}
}
let matched_tools_block = if matched.is_empty() {
"(none matched)".to_string()
} else {
matched
.iter()
.map(|(n, v, s)| format!("- {n} {v} — {s}"))
.collect::<Vec<_>>()
.join("\n")
};
let system = if is_pixi {
format!(
r#"## Role
You are a bioinformatics environment specialist. Generate a pixi environment
TOML file from the user's natural-language description.
## Tool Reference
{}
## Matched Bioconda Tools (real names + current versions)
{}
## Output Requirements
Generate ONLY valid pixi.toml inside ```toml code fences:
```toml
[project]
name = "env-name"
channels = ["conda-forge", "bioconda"]
platforms = ["linux-64"]
[dependencies]
tool = "version"
```
Rules:
- Pin every tool version using the Matched Bioconda Tools above when present; otherwise use your knowledge of current stable versions
- Include all tools the user mentions; add common companions if clearly needed
- channels order: conda-forge first, bioconda second (Bioconda's recommended channel setup)
- Do NOT add comments beyond the file header
"#,
oxo_flow_ai::knowledge::builtin::format_tool_table(),
matched_tools_block
)
} else {
format!(
r#"## Role
You are a bioinformatics environment specialist. Generate a conda environment
YAML file from the user's natural-language description.
## Tool Reference
{}
## Matched Bioconda Tools (real names + current versions)
{}
## Output Requirements
Generate ONLY valid conda environment YAML inside ```yaml code fences:
```yaml
# envs/<name>.yaml
name: <kebab-case-env-name>
channels:
- conda-forge
- bioconda
dependencies:
- <tool>=<pinned-version>
```
Rules:
- Pin every tool version using the Matched Bioconda Tools above when present; otherwise use your knowledge of current stable versions
- Include all tools the user mentions; add common companions if clearly needed
- channels order: conda-forge first, bioconda second (Bioconda's recommended channel setup)
- Do NOT add comments beyond the file header
"#,
oxo_flow_ai::knowledge::builtin::format_tool_table(),
matched_tools_block
)
};
println!("{}", " Generating...".bold().cyan());
use oxo_flow_ai::types::Message;
let messages = vec![Message::system(&system), Message::user(&description)];
let response = provider.chat_with_tools(&messages, &[]).await?;
let response_text = response.content.unwrap_or_default();
let spec_content = if is_pixi {
extract_toml(&response_text)
.ok_or_else(|| anyhow::anyhow!("AI response did not contain valid pixi TOML"))?
} else {
extract_yaml(&response_text)
.ok_or_else(|| anyhow::anyhow!("AI response did not contain valid conda YAML"))?
};
let has_deps = spec_content.contains("dependencies");
if !has_deps {
anyhow::bail!("generated spec missing 'dependencies' section");
}
let ext = if is_pixi { "toml" } else { "yaml" };
let env_name = name.unwrap_or_else(|| {
let name_key = if is_pixi {
spec_content.lines().find_map(|l| l.strip_prefix("name = "))
} else {
spec_content.lines().find_map(|l| l.strip_prefix("name:"))
};
name_key
.map(|n| n.trim().trim_matches('"').trim_matches('\'').to_string())
.filter(|n| !n.is_empty())
.unwrap_or_else(|| "generated".to_string())
});
let out_path = std::path::PathBuf::from("envs").join(format!("{env_name}.{ext}"));
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&out_path, &spec_content)?;
println!(
"{} Environment spec written to {}",
"✓".green(),
out_path.display()
);
println!();
println!(
" Review the spec: {}",
out_path.display().to_string().dimmed()
);
println!(
" Then create it with: oxo-flow env create {}",
out_path.display().to_string().dimmed()
);
Ok(())
}
fn first_fenced(response: &str, tag: &str) -> Option<String> {
oxo_flow_ai::agent::pipeline_gen::fenced_segments(response)
.into_iter()
.find(|(t, body)| t.eq_ignore_ascii_case(tag) && !body.trim().is_empty())
.map(|(_, body)| body)
}
fn fenced_or_after(response: &str, tag: &str, raw_marker: &str) -> Option<String> {
first_fenced(response, tag).or_else(|| {
response
.find(raw_marker)
.map(|pos| response[pos..].trim().to_string())
})
}
fn extract_yaml(response: &str) -> Option<String> {
fenced_or_after(response, "yaml", "name:")
}
fn extract_toml(response: &str) -> Option<String> {
fenced_or_after(response, "toml", "[project]")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn description_matching_filters_stop_words_and_short_words() {
let matched = match_description_tools("please align with using fastp for the data");
assert!(
matched.iter().any(|(name, _, _)| name == "fastp"),
"fastp must be matched: {matched:?}"
);
for (name, _, _) in &matched {
assert_ne!(name, "and");
assert_ne!(name, "using");
assert_ne!(name, "the");
}
}
#[test]
fn description_matching_requires_name_level_hit() {
let matched = match_description_tools("trimming");
for (name, _, _) in &matched {
assert!(
name.to_ascii_lowercase().contains("trim"),
"name-level match required, got {name}"
);
}
}
#[test]
fn description_matching_deduplicates_tools() {
let matched = match_description_tools("fastp fastp fastp");
let fastp_hits: Vec<_> = matched.iter().filter(|(n, _, _)| n == "fastp").collect();
assert_eq!(fastp_hits.len(), 1, "fastp must appear once: {matched:?}");
}
#[test]
fn description_matching_pins_versions_from_knowledge_base() {
let matched = match_description_tools("trim adapters with fastp and index with samtools");
let fastp = matched.iter().find(|(n, _, _)| n == "fastp").unwrap();
assert!(
!fastp.1.is_empty(),
"fastp must carry a version: {matched:?}"
);
assert!(
!fastp.2.is_empty(),
"fastp must carry a summary: {matched:?}"
);
assert!(
matched.iter().any(|(n, _, _)| n == "samtools"),
"samtools must be matched: {matched:?}"
);
}
}