use crate::cli::examples;
use crate::config;
use crate::data::{BioVaultDb, Project, ProjectYaml};
use crate::error::Result;
use crate::pipeline_spec::PipelineSpec;
use anyhow::Context;
use colored::Colorize;
use std::fs;
use std::path::{Path, PathBuf};
const PROJECT_YAML_FILE: &str = "project.yaml";
const PIPELINE_YAML_FILE: &str = "pipeline.yaml";
pub async fn import(
source: String,
name: Option<String>,
overwrite: bool,
format: Option<String>,
) -> Result<()> {
let db = BioVaultDb::new()?;
let quiet = format.as_deref() == Some("json");
let is_url = source.starts_with("http://") || source.starts_with("https://");
let project = if is_url {
import_from_url(&db, &source, name.clone(), overwrite, quiet).await?
} else {
import_from_local(&db, &source, name.clone(), overwrite, quiet)?
};
if quiet {
let response = crate::data::CliResponse::new(project);
println!("{}", serde_json::to_string_pretty(&response)?);
} else if is_url {
println!(
"\n✅ Project '{}' imported successfully!",
project.name.green()
);
println!(" Location: {}", project.project_path);
} else {
println!(
"\n✅ Project '{}' registered successfully!",
project.name.green()
);
println!(" Location: {}", project.project_path);
println!("\n💡 This project is referenced in-place.");
println!(" Any changes you make will be reflected when you run it.");
}
Ok(())
}
pub async fn import_project_record(
source: String,
name: Option<String>,
overwrite: bool,
) -> Result<Project> {
let db = BioVaultDb::new()?;
let is_url = source.starts_with("http://") || source.starts_with("https://");
if is_url {
import_from_url(&db, &source, name, overwrite, true).await
} else {
import_from_local(&db, &source, name, overwrite, true)
}
}
pub fn create_project_record(
name: String,
example: Option<String>,
target_dir: Option<PathBuf>,
) -> Result<Project> {
let project_name = name.trim();
if project_name.is_empty() {
return Err(anyhow::anyhow!("Project name cannot be empty").into());
}
if project_name == "."
|| project_name == ".."
|| project_name.contains('/')
|| project_name.contains('\\')
{
return Err(anyhow::anyhow!("Project name contains invalid characters").into());
}
let example = example.and_then(|raw| {
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
});
let email_config = crate::config::Config::load()
.map(|cfg| cfg.email)
.unwrap_or_default();
let email_trimmed = email_config.trim().to_string();
let db = BioVaultDb::new()?;
if let Some(existing) = db.get_project(project_name)? {
return Err(anyhow::anyhow!(
"Project '{}' already exists (id: {}). Please choose a different name.",
project_name,
existing.id
)
.into());
}
let (project_dir, cleanup_on_error) = if let Some(dir) = target_dir {
let project_dir = dir;
if project_dir.exists() {
if !project_dir.is_dir() {
return Err(anyhow::anyhow!(
"Target path '{}' exists but is not a directory",
project_dir.display()
)
.into());
}
let yaml_path = project_dir.join(PROJECT_YAML_FILE);
if yaml_path.exists() {
return Err(anyhow::anyhow!(
"A project.yaml already exists at {}",
yaml_path.display()
)
.into());
}
(project_dir, false)
} else {
fs::create_dir_all(&project_dir)?;
(project_dir, true)
}
} else {
let biovault_home = config::get_biovault_home()?;
let projects_dir = biovault_home.join("projects");
fs::create_dir_all(&projects_dir)?;
let project_dir = projects_dir.join(project_name);
if project_dir.exists() {
return Err(anyhow::anyhow!(
"A project named '{}' already exists at {}",
project_name,
project_dir.display()
)
.into());
}
fs::create_dir_all(&project_dir)?;
(project_dir, true)
};
let project_dir_for_cleanup = project_dir.clone();
let project_name_owned = project_name.to_string();
let build_result: Result<Project> = (|| {
if let Some(ref example_name) = example {
examples::write_example_to_directory(example_name, &project_dir).with_context(
|| {
format!(
"Failed to scaffold project from example '{}': {}",
example_name,
project_dir.display()
)
},
)?;
} else {
use crate::project_spec::{self, ProjectSpec};
let author_placeholder = if email_trimmed.is_empty() {
"user@example.com".to_string()
} else {
email_trimmed.clone()
};
if cleanup_on_error {
fs::remove_dir_all(&project_dir)?;
}
let minimal_spec = ProjectSpec {
name: project_name_owned.clone(),
author: author_placeholder,
workflow: "workflow.nf".to_string(),
template: Some("dynamic-nextflow".to_string()),
version: Some("1.0.0".to_string()),
assets: vec![],
parameters: vec![],
inputs: vec![],
outputs: vec![],
};
project_spec::scaffold_from_spec(minimal_spec, &project_dir)
.context("Failed to scaffold dynamic-nextflow project")?;
}
let yaml_path = project_dir.join(PROJECT_YAML_FILE);
let yaml_str = fs::read_to_string(&yaml_path).context("Failed to read project.yaml")?;
let mut yaml_value: serde_yaml::Value =
serde_yaml::from_str(&yaml_str).context("Invalid project.yaml format")?;
let mut author_field = email_trimmed.clone();
let mut workflow_field = "workflow.nf".to_string();
let mut template_field = example
.clone()
.unwrap_or_else(|| "dynamic-nextflow".to_string());
let mut version_field = "1.0.0".to_string();
if let serde_yaml::Value::Mapping(ref mut map) = yaml_value {
let name_key = serde_yaml::Value::String("name".to_string());
map.insert(
name_key,
serde_yaml::Value::String(project_name_owned.clone()),
);
let author_key = serde_yaml::Value::String("author".to_string());
if !email_trimmed.is_empty() {
map.insert(
author_key.clone(),
serde_yaml::Value::String(email_trimmed.clone()),
);
}
if let Some(serde_yaml::Value::String(existing)) = map.get(&author_key) {
if !existing.trim().is_empty() {
author_field = existing.clone();
}
}
let workflow_key = serde_yaml::Value::String("workflow".to_string());
if let Some(serde_yaml::Value::String(existing)) = map.get(&workflow_key) {
if !existing.trim().is_empty() {
workflow_field = existing.clone();
}
}
let template_key = serde_yaml::Value::String("template".to_string());
if !map.contains_key(&template_key) {
map.insert(
template_key.clone(),
serde_yaml::Value::String(template_field.clone()),
);
}
if let Some(serde_yaml::Value::String(existing)) = map.get(&template_key) {
if !existing.trim().is_empty() {
template_field = existing.clone();
}
}
let version_key = serde_yaml::Value::String("version".to_string());
if let Some(serde_yaml::Value::String(existing)) = map.get(&version_key) {
if !existing.trim().is_empty() {
version_field = existing.clone();
}
}
} else {
return Err(anyhow::anyhow!("project.yaml has unexpected structure").into());
}
let updated_yaml =
serde_yaml::to_string(&yaml_value).context("Failed to serialize project.yaml")?;
fs::write(&yaml_path, updated_yaml).context("Failed to update project.yaml")?;
db.register_project(
&project_name_owned,
&version_field,
&author_field,
&workflow_field,
&template_field,
&project_dir,
)?;
let created = db
.get_project(&project_name_owned)?
.ok_or_else(|| anyhow::anyhow!("Project registration completed but not found"))?;
Ok(created)
})();
match build_result {
Ok(project) => Ok(project),
Err(err) => {
if cleanup_on_error {
let _ = fs::remove_dir_all(project_dir_for_cleanup);
}
Err(err)
}
}
}
async fn import_from_url(
db: &BioVaultDb,
url: &str,
name_override: Option<String>,
overwrite: bool,
quiet: bool,
) -> Result<Project> {
if !quiet {
println!("📥 Importing project from: {}", url.cyan());
}
let raw_url = url
.replace("github.com", "raw.githubusercontent.com")
.replace("/blob/", "/");
if !quiet {
println!("📄 Downloading project.yaml...");
}
let yaml_content = download_file(&raw_url).await?;
let yaml_str = String::from_utf8(yaml_content).context("Invalid UTF-8 in project.yaml")?;
let project_yaml: ProjectYaml =
serde_yaml::from_str(&yaml_str).context("Failed to parse project.yaml")?;
let project_name = name_override.unwrap_or(project_yaml.name.clone());
let biovault_home = config::get_biovault_home()?;
let projects_dir = biovault_home.join("projects");
fs::create_dir_all(&projects_dir)?;
let dir_name = format!("{}-{}", project_name, project_yaml.version);
let project_dir = projects_dir.join(&dir_name);
if !overwrite {
let identifier = format!("{}@{}", project_name, project_yaml.version);
if let Some(mut existing) = db.get_project(&identifier)? {
let existing_path = PathBuf::from(&existing.project_path);
let existing_yaml = existing_path.join(PROJECT_YAML_FILE);
if existing_yaml.exists() && existing_path.is_dir() {
let expected_dir_name = format!("{}-{}", project_name, project_yaml.version);
let expected_path = projects_dir.join(&expected_dir_name);
if existing_path != expected_path && !existing_path.ends_with(&expected_dir_name) {
if !quiet {
println!(
" 🔄 Migrating project '{}' to versioned path format",
project_name.dimmed()
);
}
if !expected_path.exists() {
fs::rename(&existing_path, &expected_path).with_context(|| {
format!(
"Failed to migrate project directory from {} to {}",
existing_path.display(),
expected_path.display()
)
})?;
} else {
fs::remove_dir_all(&existing_path).ok();
}
db.update_project(
&project_name,
&project_yaml.version,
&project_yaml.author,
&project_yaml.workflow,
&project_yaml.template,
&expected_path,
)?;
existing.project_path = expected_path.to_string_lossy().to_string();
}
if !quiet {
println!(
" ℹ️ Project '{}' version {} already registered, reusing (id: {})",
project_name.dimmed(),
project_yaml.version.dimmed(),
existing.id
);
}
return Ok(existing);
} else {
if !quiet {
println!(
" 🧹 Cleaning up orphaned project record for '{}@{}'",
project_name.dimmed(),
project_yaml.version.dimmed()
);
}
db.delete_project(&identifier)?;
}
}
}
if project_dir.exists() {
if overwrite {
fs::remove_dir_all(&project_dir)?;
} else {
if !quiet {
println!(
" 🧹 Removing orphaned project directory: {}",
project_dir.display().to_string().dimmed()
);
}
fs::remove_dir_all(&project_dir)?;
}
}
fs::create_dir_all(&project_dir)?;
let yaml_path = project_dir.join(PROJECT_YAML_FILE);
fs::write(&yaml_path, yaml_str)?;
if !quiet {
println!("✓ Saved project.yaml");
}
let base_url = raw_url
.rsplit_once('/')
.map(|(base, _)| base)
.ok_or_else(|| anyhow::anyhow!("Invalid URL format"))?;
let workflow_url = format!("{}/{}", base_url, project_yaml.workflow);
if !quiet {
println!("📄 Downloading {}...", project_yaml.workflow);
}
let workflow_content = download_file(&workflow_url).await?;
let workflow_path = project_dir.join(&project_yaml.workflow);
fs::write(&workflow_path, workflow_content)?;
if !quiet {
println!("✓ Saved {}", project_yaml.workflow);
}
if !project_yaml.assets.is_empty() {
let assets_dir = project_dir.join("assets");
fs::create_dir_all(&assets_dir)?;
if !quiet {
println!("📦 Downloading {} assets...", project_yaml.assets.len());
}
let assets_url = format!("{}/assets", base_url);
for asset in &project_yaml.assets {
let asset_url = format!("{}/{}", assets_url, asset);
if !quiet {
println!(" - {}", asset);
}
let asset_content = download_file(&asset_url).await?;
let asset_path = assets_dir.join(asset);
if let Some(parent) = asset_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&asset_path, asset_content)?;
}
if !quiet {
println!("✓ Downloaded all assets");
}
}
if overwrite {
let identifier = format!("{}@{}", project_name, project_yaml.version);
if db.get_project(&identifier)?.is_some() {
db.update_project(
&project_name,
&project_yaml.version,
&project_yaml.author,
&project_yaml.workflow,
&project_yaml.template,
&project_dir,
)?;
} else {
db.register_project(
&project_name,
&project_yaml.version,
&project_yaml.author,
&project_yaml.workflow,
&project_yaml.template,
&project_dir,
)?;
}
} else {
db.register_project(
&project_name,
&project_yaml.version,
&project_yaml.author,
&project_yaml.workflow,
&project_yaml.template,
&project_dir,
)?;
}
let project = db
.get_project(&project_name)?
.ok_or_else(|| anyhow::anyhow!("Project '{}' not found after import", project_name))?;
Ok(project)
}
async fn copy_local_project_to_managed(
db: &BioVaultDb,
source_path: &Path,
overwrite: bool,
quiet: bool,
) -> Result<Project> {
if !source_path.exists() {
return Err(anyhow::anyhow!("Source path does not exist: {}", source_path.display()).into());
}
if !source_path.is_dir() {
return Err(anyhow::anyhow!("Source path is not a directory: {}", source_path.display()).into());
}
let yaml_path = source_path.join(PROJECT_YAML_FILE);
if !yaml_path.exists() {
return Err(anyhow::anyhow!(
"No project.yaml found in directory: {}",
source_path.display()
)
.into());
}
let yaml_str = fs::read_to_string(&yaml_path)?;
let project_yaml: ProjectYaml = serde_yaml::from_str(&yaml_str)?;
let project_name = project_yaml.name.clone();
let biovault_home = config::get_biovault_home()?;
let projects_dir = biovault_home.join("projects");
fs::create_dir_all(&projects_dir)?;
let dir_name = format!("{}-{}", project_name, project_yaml.version);
let project_dir = projects_dir.join(&dir_name);
if !overwrite {
let identifier = format!("{}@{}", project_name, project_yaml.version);
if let Some(existing) = db.get_project(&identifier)? {
let existing_path = PathBuf::from(&existing.project_path);
let existing_yaml = existing_path.join(PROJECT_YAML_FILE);
if existing_yaml.exists() && existing_path.is_dir() {
if !quiet {
println!(
" ℹ️ Project '{}' version {} already imported, reusing (id: {})",
project_name,
project_yaml.version,
existing.id
);
}
return Ok(existing);
} else {
if !quiet {
println!(
" 🧹 Cleaning up orphaned project record for '{}@{}'",
project_name,
project_yaml.version
);
}
db.delete_project(&identifier)?;
}
}
}
if project_dir.exists() {
if overwrite {
fs::remove_dir_all(&project_dir)?;
} else {
if !quiet {
println!(
" 🧹 Removing orphaned project directory: {}",
project_dir.display().to_string()
);
}
fs::remove_dir_all(&project_dir)?;
}
}
fs::create_dir_all(&project_dir)?;
let dest_yaml_path = project_dir.join(PROJECT_YAML_FILE);
fs::copy(&yaml_path, &dest_yaml_path)?;
if !quiet {
println!("✓ Copied project.yaml");
}
let workflow_source = source_path.join(&project_yaml.workflow);
if workflow_source.exists() {
let workflow_dest = project_dir.join(&project_yaml.workflow);
if let Some(parent) = workflow_dest.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&workflow_source, &workflow_dest)?;
if !quiet {
println!("✓ Copied {}", project_yaml.workflow);
}
} else if !quiet {
println!("⚠️ Warning: workflow file '{}' not found in source", project_yaml.workflow);
}
if !project_yaml.assets.is_empty() {
let assets_dir = project_dir.join("assets");
fs::create_dir_all(&assets_dir)?;
if !quiet {
println!("📦 Copying {} assets...", project_yaml.assets.len());
}
for asset in &project_yaml.assets {
let asset_source = source_path.join("assets").join(asset);
if asset_source.exists() {
let asset_dest = assets_dir.join(asset);
if let Some(parent) = asset_dest.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&asset_source, &asset_dest)?;
if !quiet {
println!(" ✓ {}", asset);
}
} else if !quiet {
println!(" ⚠️ Warning: asset '{}' not found", asset);
}
}
if !quiet {
println!("✓ Copied all assets");
}
}
if overwrite {
let identifier = format!("{}@{}", project_name, project_yaml.version);
if db.get_project(&identifier)?.is_some() {
db.update_project(
&project_name,
&project_yaml.version,
&project_yaml.author,
&project_yaml.workflow,
&project_yaml.template,
&project_dir,
)?;
} else {
db.register_project(
&project_name,
&project_yaml.version,
&project_yaml.author,
&project_yaml.workflow,
&project_yaml.template,
&project_dir,
)?;
}
} else {
db.register_project(
&project_name,
&project_yaml.version,
&project_yaml.author,
&project_yaml.workflow,
&project_yaml.template,
&project_dir,
)?;
}
let project = db
.get_project(&project_name)?
.ok_or_else(|| anyhow::anyhow!("Project '{}' not found after import", project_name))?;
Ok(project)
}
fn import_from_local(
db: &BioVaultDb,
path: &str,
name_override: Option<String>,
overwrite: bool,
quiet: bool,
) -> Result<Project> {
let project_path = PathBuf::from(path);
if !project_path.exists() {
return Err(anyhow::anyhow!("Path does not exist: {}", path).into());
}
if !project_path.is_dir() {
return Err(anyhow::anyhow!("Path is not a directory: {}", path).into());
}
let yaml_path = project_path.join(PROJECT_YAML_FILE);
if !yaml_path.exists() {
return Err(anyhow::anyhow!(
"No project.yaml found in directory: {}",
project_path.display()
)
.into());
}
if !quiet {
println!("📁 Registering local project: {}", path.cyan());
}
let yaml_str = fs::read_to_string(&yaml_path)?;
let project_yaml: ProjectYaml = serde_yaml::from_str(&yaml_str)?;
let project_name = name_override.unwrap_or(project_yaml.name.clone());
if !overwrite {
if let Some(existing) = db.get_project(&project_name)? {
let existing_path = PathBuf::from(&existing.project_path);
let canonical_existing = existing_path.canonicalize().ok();
let canonical_new = project_path.canonicalize().ok();
if canonical_existing == canonical_new && canonical_existing.is_some() {
if !quiet {
println!(
" ℹ️ Project '{}' already registered at this path (id: {})",
project_name.dimmed(),
existing.id
);
}
return Ok(existing);
} else {
return Err(anyhow::anyhow!(
"Project '{}' already exists (id: {}) at different path: {}. Use --overwrite to replace.",
project_name,
existing.id,
existing.project_path
)
.into());
}
}
}
if overwrite {
let identifier = format!("{}@{}", project_name, project_yaml.version);
if db.get_project(&identifier)?.is_some() {
db.update_project(
&project_name,
&project_yaml.version,
&project_yaml.author,
&project_yaml.workflow,
&project_yaml.template,
&project_path,
)?;
} else {
db.register_project(
&project_name,
&project_yaml.version,
&project_yaml.author,
&project_yaml.workflow,
&project_yaml.template,
&project_path,
)?;
}
} else {
db.register_project(
&project_name,
&project_yaml.version,
&project_yaml.author,
&project_yaml.workflow,
&project_yaml.template,
&project_path,
)?;
}
let project = db
.get_project(&project_name)?
.ok_or_else(|| anyhow::anyhow!("Project '{}' not found after import", project_name))?;
Ok(project)
}
pub fn list(format: Option<String>) -> Result<()> {
let db = BioVaultDb::new()?;
let projects = db.list_projects()?;
if format.as_deref() == Some("json") {
let response = crate::data::CliResponse::new(projects);
println!("{}", serde_json::to_string_pretty(&response)?);
return Ok(());
}
if projects.is_empty() {
println!("📭 No projects found");
println!("\n💡 Import a project:");
println!(" bv project import <url>");
println!(" bv project import /path/to/project");
return Ok(());
}
println!("\n📦 {} project(s):\n", projects.len());
println!(
"{:<4} {:<20} {:<25} {:<15} {:<20}",
"ID".bold(),
"Name".bold(),
"Author".bold(),
"Workflow".bold(),
"Created".bold()
);
println!("{}", "─".repeat(90));
for project in projects {
println!(
"{:<4} {:<20} {:<25} {:<15} {:<20}",
project.id,
truncate(&project.name, 18),
truncate(&project.author, 23),
truncate(&project.workflow, 13),
&project.created_at[..19] );
}
println!();
Ok(())
}
pub fn show(identifier: String, format: Option<String>) -> Result<()> {
let db = BioVaultDb::new()?;
let project = db
.get_project(&identifier)?
.ok_or_else(|| anyhow::anyhow!("Project '{}' not found", identifier))?;
if format.as_deref() == Some("json") {
let response = crate::data::CliResponse::new(project);
println!("{}", serde_json::to_string_pretty(&response)?);
return Ok(());
}
let run_count = db.count_project_runs(project.id)?;
println!("\n{}", "═".repeat(80));
println!("📦 {}", project.name.bold().cyan());
println!("{}", "─".repeat(80));
println!("ID: {}", project.id);
println!("Author: {}", project.author);
println!("Workflow: {}", project.workflow);
println!("Template: {}", project.template);
println!("Location: {}", project.project_path);
println!("Created: {}", project.created_at);
println!("Runs: {}", run_count);
println!("{}", "═".repeat(80));
println!();
let yaml_path = Path::new(&project.project_path).join(PROJECT_YAML_FILE);
if yaml_path.exists() {
println!("📄 project.yaml:");
println!("{}", "─".repeat(80));
let yaml_content = fs::read_to_string(&yaml_path)?;
for line in yaml_content.lines().take(20) {
println!(" {}", line.dimmed());
}
println!("{}", "─".repeat(80));
println!();
}
Ok(())
}
pub fn delete(identifier: String, keep_files: bool, format: Option<String>) -> Result<()> {
let db = BioVaultDb::new()?;
let project = db.delete_project(&identifier)?;
if format.as_deref() == Some("json") {
let response = crate::data::CliResponse::new(serde_json::json!({
"deleted": project,
"files_kept": keep_files
}));
println!("{}", serde_json::to_string_pretty(&response)?);
return Ok(());
}
println!(
"✅ Deleted project '{}' from database",
project.name.green()
);
if !keep_files {
let project_path = Path::new(&project.project_path);
if project_path.exists() {
fs::remove_dir_all(project_path)?;
println!("✅ Deleted project files: {}", project_path.display());
}
} else {
println!("📁 Project files kept: {}", project.project_path);
}
Ok(())
}
#[derive(Debug, Clone)]
pub enum DependencyContext {
GitHub { base_url: String },
Local { base_path: PathBuf },
}
pub async fn resolve_pipeline_dependencies(
spec: &mut PipelineSpec,
dependency_context: &DependencyContext,
pipeline_yaml_path: &Path,
overwrite: bool,
quiet: bool,
) -> Result<bool> {
use colored::Colorize;
let db = BioVaultDb::new()?;
let mut any_rewritten = false;
if spec.steps.is_empty() {
return Ok(false);
}
if !quiet {
println!(
"\n{} Importing dependencies ({} steps):",
"📦".cyan(),
spec.steps.len()
);
}
let mut step_updates: Vec<(usize, String)> = Vec::new();
for (index, step) in spec.steps.iter().enumerate() {
if let Some(uses) = &step.uses {
if uses.starts_with('/') {
if !quiet {
println!(
" {} Step '{}' uses absolute path (keeping as-is)",
"ℹ️ ".cyan(),
step.id.dimmed()
);
}
continue;
}
let should_skip_as_registered = match dependency_context {
DependencyContext::GitHub { .. } => {
!uses.contains('/') && !uses.starts_with("http")
}
DependencyContext::Local { .. } => {
false }
};
if should_skip_as_registered {
if !quiet {
println!(
" {} Step '{}' already uses name (keeping as-is)",
"ℹ️ ".cyan(),
step.id.dimmed()
);
}
continue;
}
let (should_use_local, local_path_opt, url_opt) = if uses.starts_with("http://") || uses.starts_with("https://") {
(false, None, Some(format!("{}/project.yaml", uses)))
} else {
match dependency_context {
DependencyContext::GitHub { base_url } => {
(false, None, Some(format!("{}/{}/project.yaml", base_url, uses)))
}
DependencyContext::Local { base_path } => {
let project_path = base_path.join(uses);
let project_yaml_path = if project_path.is_dir() {
project_path.join(PROJECT_YAML_FILE)
} else if project_path.extension().map(|e| e == "yaml" || e == "yml").unwrap_or(false) {
project_path.clone()
} else {
project_path.join(PROJECT_YAML_FILE)
};
if project_yaml_path.exists() {
let project_dir = if project_path.is_dir() {
project_path
} else {
project_yaml_path.parent().unwrap_or(&base_path).to_path_buf()
};
(true, Some(project_dir), None)
} else {
let db_check = db.get_project(uses).ok().flatten();
if db_check.is_some() {
if !quiet {
println!(
" {} Step '{}' uses registered name '{}' (keeping as-is)",
"ℹ️ ".cyan(),
step.id.dimmed(),
uses
);
}
continue;
}
(false, None, None)
}
}
}
};
if !quiet {
print!(" {} {} ", "•".cyan(), step.id.bold());
}
let import_result = if should_use_local {
if let Some(local_path) = local_path_opt {
let existing_projects = db.list_projects().context("Failed to list projects")?;
let already_registered = existing_projects.iter().any(|p| {
PathBuf::from(&p.project_path).canonicalize().ok() == local_path.canonicalize().ok()
});
if already_registered {
let yaml_path = local_path.join(PROJECT_YAML_FILE);
if yaml_path.exists() {
let yaml_str = fs::read_to_string(&yaml_path)
.context("Failed to read project.yaml")?;
let project_yaml: ProjectYaml = serde_yaml::from_str(&yaml_str)
.context("Failed to parse project.yaml")?;
let identifier = format!("{}@{}", project_yaml.name, project_yaml.version);
match db.get_project(&identifier) {
Ok(Some(project)) => {
Ok((project, true))
}
_ => {
copy_local_project_to_managed(&db, &local_path, overwrite, true)
.await
.map(|p| (p, true))
}
}
} else {
return Err(anyhow::anyhow!("project.yaml not found at {}", local_path.display()).into());
}
} else {
copy_local_project_to_managed(&db, &local_path, overwrite, true)
.await
.map(|p| (p, true))
}
} else {
return Err(anyhow::anyhow!("Local path not available for dependency '{}'", uses).into());
}
} else if let Some(url) = url_opt {
import_from_url(&db, &url, None, overwrite, true)
.await
.map(|p| (p, false))
} else {
if let DependencyContext::Local { base_path: _ } = dependency_context {
return Err(anyhow::anyhow!("Cannot resolve dependency '{}': local path doesn't exist and no URL available", uses).into());
} else {
return Err(anyhow::anyhow!("Cannot resolve dependency '{}': no valid source", uses).into());
}
};
match import_result {
Ok((project, _is_local)) => {
if !quiet {
println!("{} → {}", "✓ imported".green(), project.name.green());
}
step_updates.push((index, project.name.clone()));
any_rewritten = true;
}
Err(e) => {
if !quiet {
println!("{}: {}", "⚠️ failed".yellow(), e.to_string().dimmed());
}
}
}
}
}
for (index, project_name) in step_updates {
if let Some(updated_step) = spec.steps.get_mut(index) {
updated_step.uses = Some(project_name);
}
}
if any_rewritten {
if !quiet {
println!(
"\n{} Updating pipeline.yaml to use registered names...",
"🔧".cyan()
);
}
let updated_yaml =
serde_yaml::to_string(spec).context("Failed to serialize updated spec")?;
fs::write(pipeline_yaml_path, updated_yaml)
.context("Failed to write updated pipeline.yaml")?;
if !quiet {
println!(
"{} Pipeline YAML updated with registered names!",
"✓".green()
);
}
}
Ok(any_rewritten)
}
pub async fn import_pipeline_with_deps(
url: &str,
name_override: Option<String>,
overwrite: bool,
) -> Result<String> {
use crate::pipeline_spec::PipelineSpec;
use colored::Colorize;
let raw_url = url
.replace("github.com", "raw.githubusercontent.com")
.replace("/blob/", "/");
println!("{} Downloading pipeline from {}", "📥".cyan(), url.cyan());
let yaml_content = download_file(&raw_url).await?;
let yaml_str = String::from_utf8(yaml_content).context("Invalid UTF-8 in pipeline.yaml")?;
let mut spec: PipelineSpec =
serde_yaml::from_str(&yaml_str).context("Failed to parse pipeline.yaml")?;
let pipeline_name = name_override.unwrap_or_else(|| spec.name.clone());
println!("{} Pipeline: {}", "📦".cyan(), pipeline_name.bold());
println!(" Steps: {}", spec.steps.len());
let biovault_home = crate::config::get_biovault_home()?;
let pipelines_dir = biovault_home.join("pipelines");
fs::create_dir_all(&pipelines_dir)?;
let pipeline_dir = pipelines_dir.join(&pipeline_name);
if pipeline_dir.exists() {
if overwrite {
fs::remove_dir_all(&pipeline_dir)?;
} else {
return Err(anyhow::anyhow!(
"Pipeline directory already exists: {}. Use --overwrite to replace.",
pipeline_dir.display()
)
.into());
}
}
fs::create_dir_all(&pipeline_dir)?;
let pipeline_yaml_path = pipeline_dir.join(PIPELINE_YAML_FILE);
fs::write(&pipeline_yaml_path, &yaml_str)?;
println!("{} Saved pipeline.yaml", "✓".green());
let base_url = if let Some(idx) = url.rfind('/') {
url[..idx].to_string()
} else {
url.to_string()
};
resolve_pipeline_dependencies(
&mut spec,
&DependencyContext::GitHub { base_url },
&pipeline_yaml_path,
overwrite,
false, )
.await?;
let db = BioVaultDb::new()?;
let pipeline_id = if overwrite {
let existing_pipelines = db.list_pipelines()?;
if let Some(existing) = existing_pipelines.iter().find(|p| p.name == pipeline_name) {
db.delete_pipeline(existing.id)?;
}
db.register_pipeline(&pipeline_name, &pipeline_dir.to_string_lossy())?
} else {
db.register_pipeline(&pipeline_name, &pipeline_dir.to_string_lossy())?
};
println!(
"\n{} Pipeline '{}' imported successfully!",
"✅".green().bold(),
pipeline_name.bold()
);
println!(
" Location: {}",
pipeline_dir.display().to_string().dimmed()
);
println!(" ID: {}", pipeline_id);
Ok(pipeline_dir.to_string_lossy().to_string())
}
async fn download_file(url: &str) -> Result<Vec<u8>> {
let response = reqwest::get(url)
.await
.map_err(|e| anyhow::anyhow!("Failed to download {}: {}", url, e))?;
if !response.status().is_success() {
return Err(
anyhow::anyhow!("Failed to download {}: HTTP {}", url, response.status()).into(),
);
}
let bytes = response
.bytes()
.await
.map_err(|e| anyhow::anyhow!("Failed to read response bytes: {}", e))?
.to_vec();
Ok(bytes)
}
fn truncate(s: &str, max_len: usize) -> String {
if s.len() > max_len {
format!("{}...", &s[..max_len - 3])
} else {
s.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn setup_test() -> TempDir {
let tmp = TempDir::new().unwrap();
crate::config::set_test_biovault_home(tmp.path());
tmp
}
fn teardown_test() {
crate::config::clear_test_biovault_home();
}
#[test]
fn test_import_from_local() {
let tmp = setup_test();
let project_dir = tmp.path().join("test-project");
fs::create_dir_all(&project_dir).unwrap();
let yaml_content = r#"
name: test-project
author: test@example.com
workflow: workflow.nf
template: default
assets: []
"#;
fs::write(project_dir.join(PROJECT_YAML_FILE), yaml_content).unwrap();
fs::write(project_dir.join("workflow.nf"), "// workflow").unwrap();
let project = import_from_local(
&BioVaultDb::new().unwrap(),
project_dir.to_str().unwrap(),
None,
false,
true,
)
.expect("local project import should succeed");
assert_eq!(project.name, "test-project");
let db = BioVaultDb::new().unwrap();
let project = db.get_project("test-project").unwrap();
assert!(project.is_some());
teardown_test();
}
#[test]
fn test_list_projects() {
let tmp = setup_test();
let db = BioVaultDb::new().unwrap();
let project_path = tmp.path().join("project1");
fs::create_dir_all(&project_path).unwrap();
db.register_project(
"project1",
"1.0.0",
"author@example.com",
"workflow.nf",
"default",
&project_path,
)
.unwrap();
let result = list(None);
assert!(result.is_ok());
teardown_test();
}
#[test]
fn test_truncate() {
assert_eq!(truncate("short", 10), "short");
assert_eq!(truncate("verylongstring", 10), "verylon...");
}
}