use crate::cli::commands::messages::init_message_system;
use crate::cli::syft_url::SyftURL;
use crate::config::Config;
use crate::error::{Error, Result};
use crate::messages::{Message, MessageType};
use crate::types::{ProjectYaml, SyftPermissions};
use anyhow::Context;
use chrono::Local;
use dialoguer::{Confirm, Editor};
use serde_json::json;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
pub async fn submit(
project_path: String,
destination: String,
non_interactive: bool,
force: bool,
) -> Result<()> {
let config = Config::load()?;
let (datasite_email, participant_url) = if destination.starts_with("syft://") {
let dest_url = SyftURL::parse(&destination)?;
let email = dest_url.email.clone();
let participant = if dest_url.fragment.is_some() {
Some(destination.clone())
} else {
None
};
(email, participant)
} else if destination.contains('@') {
(destination.clone(), None)
} else {
return Err(Error::from(anyhow::anyhow!(
"Invalid destination: must be either an email address or a syft:// URL"
)));
};
let project_dir = PathBuf::from(&project_path);
let project_dir = if project_dir.is_relative() && project_path == "." {
std::env::current_dir()?
} else {
project_dir
};
if !project_dir.exists() {
return Err(Error::from(anyhow::anyhow!(
"Project directory not found: {}",
project_dir.display()
)));
}
let project_yaml_path = project_dir.join("project.yaml");
if !project_yaml_path.exists() {
return Err(Error::from(anyhow::anyhow!(
"project.yaml not found in: {}",
project_dir.display()
)));
}
let mut project = ProjectYaml::from_file(&project_yaml_path)?;
project.author = config.email.clone();
project.datasites = Some(vec![datasite_email.clone()]);
project.participants = participant_url.clone().map(|url| vec![url]);
let workflow_path = project_dir.join("workflow.nf");
if !workflow_path.exists() {
return Err(Error::from(anyhow::anyhow!(
"workflow.nf not found in project directory"
)));
}
let workflow_hash = hash_file(&workflow_path)?;
let assets_dir = project_dir.join("assets");
let mut asset_files = Vec::new();
let mut b3_hashes = HashMap::new();
b3_hashes.insert("workflow.nf".to_string(), workflow_hash.clone());
if assets_dir.exists() && assets_dir.is_dir() {
for entry in WalkDir::new(&assets_dir)
.min_depth(1)
.follow_links(false)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
{
let path = entry.path();
let relative_path = path
.strip_prefix(&project_dir)
.unwrap_or(path)
.to_string_lossy()
.to_string();
let assets_rel = path
.strip_prefix(&assets_dir)
.unwrap_or(path)
.to_string_lossy()
.to_string();
asset_files.push(assets_rel);
let file_hash = hash_file(path)?;
b3_hashes.insert(relative_path, file_hash);
}
}
project.assets = if asset_files.is_empty() {
None
} else {
Some(asset_files)
};
project.b3_hashes = Some(b3_hashes);
let mut hash_content = String::new();
hash_content.push_str(&project.name);
hash_content.push_str(&workflow_hash);
if let Some(ref hashes) = project.b3_hashes {
let mut sorted_hashes: Vec<_> = hashes.iter().collect();
sorted_hashes.sort_by_key(|k| k.0);
for (file, hash) in sorted_hashes {
hash_content.push_str(file);
hash_content.push_str(hash);
}
}
let project_hash = blake3::hash(hash_content.as_bytes()).to_hex().to_string();
let date_str = Local::now().format("%Y-%m-%d").to_string();
let short_hash = &project_hash[0..8];
let submission_folder_name = format!("{}-{}-{}", project.name, date_str, short_hash);
let submissions_path = config.get_shared_submissions_path()?;
fs::create_dir_all(&submissions_path)?;
let submission_path = submissions_path.join(&submission_folder_name);
if submission_path.exists() {
let existing_project_yaml = submission_path.join("project.yaml");
if existing_project_yaml.exists() {
let existing_project = ProjectYaml::from_file(&existing_project_yaml)?;
let mut existing_hash_content = String::new();
existing_hash_content.push_str(&existing_project.name);
if let Some(ref existing_hashes) = existing_project.b3_hashes {
if let Some(existing_workflow_hash) = existing_hashes.get("workflow.nf") {
existing_hash_content.push_str(existing_workflow_hash);
let mut sorted_hashes: Vec<_> = existing_hashes.iter().collect();
sorted_hashes.sort_by_key(|k| k.0);
for (file, hash) in sorted_hashes {
existing_hash_content.push_str(file);
existing_hash_content.push_str(hash);
}
}
}
let existing_hash = blake3::hash(existing_hash_content.as_bytes())
.to_hex()
.to_string();
if existing_hash == project_hash {
if !force {
println!("⚠️ This exact project version has already been submitted.");
println!(" Location: {}", submission_path.display());
println!(" Hash: {}", short_hash);
println!(" Use --force to resend the message.");
return Ok(());
} else {
println!(
"ℹ️ Project already submitted, but --force flag used. Sending message..."
);
println!(" Location: {}", submission_path.display());
println!(" Hash: {}", short_hash);
let datasite_root = config.get_datasite_path()?;
let rel_from_datasite = submission_path
.strip_prefix(&datasite_root)
.unwrap_or(&submission_path)
.to_string_lossy()
.to_string();
let submission_syft_url =
format!("syft://{}/{}", config.email, rel_from_datasite);
let default_body = "I would like to run the following project.".to_string();
let mut body = if non_interactive {
default_body.clone()
} else {
let use_custom = Confirm::new()
.with_prompt("Write a custom message body?")
.default(false)
.interact()
.unwrap_or(false);
if use_custom {
match Editor::new().edit(&default_body) {
Ok(Some(content)) if !content.trim().is_empty() => content,
_ => default_body.clone(),
}
} else {
default_body.clone()
}
};
let sender_local_path = submission_path.to_string_lossy().to_string();
let receiver_local_path_template = format!(
"$SYFTBOX_DATA_DIR/datasites/{}/shared/biovault/submissions/{}",
config.email, submission_folder_name
);
body.push_str(&format!(
"\n\nSubmission location references:\n- syft URL: {}\n- Sender local path: {}\n- Receiver local path (template): {}\n",
submission_syft_url, sender_local_path, receiver_local_path_template
));
let metadata = json!({
"project": existing_project,
"project_location": submission_syft_url,
"participants": "With your participants: ALL",
"date": date_str,
"assets": existing_project.assets.clone().unwrap_or_default(),
"sender_local_path": sender_local_path,
"receiver_local_path_template": receiver_local_path_template,
});
let (db, sync) = init_message_system(&config)?;
let mut msg = Message::new(config.email.clone(), datasite_email.clone(), body);
msg.subject = Some(format!("Project Request - {}", existing_project.name));
msg.message_type = MessageType::Project {
project_name: existing_project.name.clone(),
submission_id: submission_folder_name.clone(),
files_hash: Some(existing_hash.clone()),
};
msg.metadata = Some(metadata);
db.insert_message(&msg)?;
let _ = sync.send_message(&msg.id);
println!("✉️ Project message prepared for {}", datasite_email);
if let Some(subj) = &msg.subject {
println!(" Subject: {}", subj);
}
println!(" Submission URL: {}", submission_syft_url);
return Ok(());
}
}
}
return Err(Error::from(anyhow::anyhow!(
"A submission with this name and date already exists but with different content: {}",
submission_path.display()
)));
}
fs::create_dir_all(&submission_path)?;
copy_project_files(&project_dir, &submission_path)?;
project.save(&submission_path.join("project.yaml"))?;
let permissions = SyftPermissions::new_for_datasite(&datasite_email);
let permissions_path = submission_path.join("syft.pub.yaml");
permissions.save(&permissions_path)?;
println!("✓ Project submitted successfully!");
println!(" Name: {}", project.name);
println!(" To: {}", datasite_email);
if let Some(participants) = &project.participants {
println!(" Participants: {}", participants.join(", "));
}
println!(" Location: {}", submission_path.display());
println!(" Hash: {}", short_hash);
let datasite_root = config.get_datasite_path()?;
let rel_from_datasite = submission_path
.strip_prefix(&datasite_root)
.unwrap_or(&submission_path)
.to_string_lossy()
.to_string();
let submission_syft_url = format!("syft://{}/{}", config.email, rel_from_datasite);
let default_body = "I would like to run the following project.".to_string();
let mut body = if non_interactive {
default_body.clone()
} else {
let use_custom = Confirm::new()
.with_prompt("Write a custom message body?")
.default(false)
.interact()
.unwrap_or(false);
if use_custom {
match Editor::new().edit(&default_body) {
Ok(Some(content)) if !content.trim().is_empty() => content,
_ => default_body.clone(),
}
} else {
default_body.clone()
}
};
let sender_local_path = submission_path.to_string_lossy().to_string();
let receiver_local_path_template = format!(
"$SYFTBOX_DATA_DIR/datasites/{}/shared/biovault/submissions/{}",
config.email, submission_folder_name
);
body.push_str(&format!(
"\n\nSubmission location references:\n- syft URL: {}\n- Sender local path: {}\n- Receiver local path (template): {}\n",
submission_syft_url, sender_local_path, receiver_local_path_template
));
let metadata = json!({
"project": project,
"project_location": submission_syft_url,
"participants": "With your participants: ALL",
"date": date_str,
"assets": project.assets.clone().unwrap_or_default(),
"sender_local_path": sender_local_path,
"receiver_local_path_template": receiver_local_path_template,
});
let (db, sync) = init_message_system(&config)?;
let mut msg = Message::new(config.email.clone(), datasite_email.clone(), body);
msg.subject = Some(format!("Project Request - {}", project.name));
msg.message_type = MessageType::Project {
project_name: project.name.clone(),
submission_id: submission_folder_name.clone(),
files_hash: Some(project_hash.clone()),
};
msg.metadata = Some(metadata);
db.insert_message(&msg)?;
let _ = sync.send_message(&msg.id);
println!("✉️ Project message prepared for {}", datasite_email);
if let Some(subj) = &msg.subject {
println!(" Subject: {}", subj);
}
println!(" Submission URL: {}", submission_syft_url);
Ok(())
}
fn hash_file(path: &Path) -> Result<String> {
let content =
fs::read(path).with_context(|| format!("Failed to read file: {}", path.display()))?;
Ok(blake3::hash(&content).to_hex().to_string())
}
fn copy_project_files(src: &Path, dest: &Path) -> Result<()> {
let src_workflow = src.join("workflow.nf");
let dest_workflow = dest.join("workflow.nf");
fs::copy(&src_workflow, &dest_workflow)
.with_context(|| "Failed to copy workflow.nf".to_string())?;
let src_assets = src.join("assets");
if src_assets.exists() && src_assets.is_dir() {
let dest_assets = dest.join("assets");
fs::create_dir_all(&dest_assets)?;
for entry in WalkDir::new(&src_assets)
.min_depth(1)
.follow_links(false)
.into_iter()
.filter_map(|e| e.ok())
{
let src_path = entry.path();
let relative_path = src_path.strip_prefix(&src_assets).unwrap();
let dest_path = dest_assets.join(relative_path);
if entry.file_type().is_dir() {
fs::create_dir_all(&dest_path)?;
} else {
if let Some(parent) = dest_path.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(src_path, &dest_path)
.with_context(|| format!("Failed to copy file: {}", src_path.display()))?;
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn hash_file_computes_blake3() {
let tmp = TempDir::new().unwrap();
let f = tmp.path().join("a.txt");
fs::write(&f, b"content").unwrap();
let h = hash_file(&f).unwrap();
assert_eq!(h.len(), 64);
}
#[test]
fn copy_project_files_copies_workflow_and_assets() {
let tmp = TempDir::new().unwrap();
let src = tmp.path().join("src");
let dest = tmp.path().join("dest");
fs::create_dir_all(&dest).unwrap();
fs::create_dir_all(src.join("assets/nested")).unwrap();
fs::write(src.join("workflow.nf"), b"wf").unwrap();
fs::write(src.join("assets/nested/file.bin"), b"x").unwrap();
copy_project_files(&src, &dest).unwrap();
assert!(dest.join("workflow.nf").exists());
assert!(dest.join("assets/nested/file.bin").exists());
let src2 = tmp.path().join("src2");
fs::create_dir_all(&src2).unwrap();
fs::write(src2.join("workflow.nf"), b"wf").unwrap();
let dest2 = tmp.path().join("dest2");
fs::create_dir_all(&dest2).unwrap();
copy_project_files(&src2, &dest2).unwrap();
}
#[test]
fn test_hash_file_with_different_content() {
let tmp = TempDir::new().unwrap();
let file1 = tmp.path().join("file1.txt");
fs::write(&file1, b"content1").unwrap();
let hash1 = hash_file(&file1).unwrap();
let file2 = tmp.path().join("file2.txt");
fs::write(&file2, b"content2").unwrap();
let hash2 = hash_file(&file2).unwrap();
assert_ne!(hash1, hash2);
let file3 = tmp.path().join("file3.txt");
fs::write(&file3, b"content1").unwrap();
let hash3 = hash_file(&file3).unwrap();
assert_eq!(hash1, hash3);
}
#[test]
fn test_hash_file_empty_file() {
let tmp = TempDir::new().unwrap();
let empty_file = tmp.path().join("empty.txt");
fs::write(&empty_file, b"").unwrap();
let hash = hash_file(&empty_file).unwrap();
assert_eq!(hash.len(), 64);
}
#[test]
fn test_copy_project_files_missing_workflow() {
let tmp = TempDir::new().unwrap();
let src = tmp.path().join("src");
let dest = tmp.path().join("dest");
fs::create_dir_all(&src).unwrap();
fs::create_dir_all(&dest).unwrap();
let result = copy_project_files(&src, &dest);
assert!(result.is_err());
}
#[test]
fn test_copy_project_files_with_symlinks() {
let tmp = TempDir::new().unwrap();
let src = tmp.path().join("src");
let dest = tmp.path().join("dest");
fs::create_dir_all(&src).unwrap();
fs::create_dir_all(&dest).unwrap();
fs::create_dir_all(src.join("assets")).unwrap();
fs::write(src.join("workflow.nf"), b"workflow").unwrap();
fs::write(src.join("assets/real_file.txt"), b"real").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
let _ = symlink(
src.join("assets/real_file.txt"),
src.join("assets/link_file.txt"),
);
}
let result = copy_project_files(&src, &dest);
assert!(result.is_ok());
assert!(dest.join("workflow.nf").exists());
}
#[test]
fn test_copy_project_files_deeply_nested() {
let tmp = TempDir::new().unwrap();
let src = tmp.path().join("src");
let dest = tmp.path().join("dest");
fs::create_dir_all(&dest).unwrap();
fs::create_dir_all(src.join("assets/a/b/c/d")).unwrap();
fs::write(src.join("workflow.nf"), b"wf").unwrap();
fs::write(src.join("assets/a/b/c/d/deep.txt"), b"deep").unwrap();
copy_project_files(&src, &dest).unwrap();
assert!(dest.join("workflow.nf").exists());
assert!(dest.join("assets/a/b/c/d/deep.txt").exists());
let content = fs::read(dest.join("assets/a/b/c/d/deep.txt")).unwrap();
assert_eq!(content, b"deep");
}
#[test]
fn test_copy_project_files_preserves_content() {
let tmp = TempDir::new().unwrap();
let src = tmp.path().join("src");
let dest = tmp.path().join("dest");
fs::create_dir_all(&dest).unwrap();
fs::create_dir_all(src.join("assets")).unwrap();
let workflow_content = b"nextflow.enable.dsl=2\nworkflow { }";
let asset_content = b"important data";
fs::write(src.join("workflow.nf"), workflow_content).unwrap();
fs::write(src.join("assets/data.csv"), asset_content).unwrap();
copy_project_files(&src, &dest).unwrap();
let copied_workflow = fs::read(dest.join("workflow.nf")).unwrap();
let copied_asset = fs::read(dest.join("assets/data.csv")).unwrap();
assert_eq!(copied_workflow, workflow_content);
assert_eq!(copied_asset, asset_content);
}
#[test]
fn test_hash_file_nonexistent() {
let result = hash_file(Path::new("/nonexistent/file.txt"));
assert!(result.is_err());
}
}