use anyhow::{anyhow, Context, Result};
use colored::Colorize;
use dialoguer::{Confirm, Input, Select};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use tracing::warn;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Participant {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub ref_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub r#ref: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ref_index: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aligned: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aligned_index: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub snp: Option<String>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct ParticipantsFile {
pub participants: HashMap<String, Participant>,
}
impl ParticipantsFile {
pub fn new() -> Self {
Self::default()
}
pub fn load() -> anyhow::Result<Self> {
let path = get_participants_file_path()?;
if !path.exists() {
Ok(Self::new())
} else {
let contents = fs::read_to_string(&path).with_context(|| {
format!("Failed to read participants file at {}", path.display())
})?;
let parsed: Self = serde_yaml::from_str(&contents)
.with_context(|| "Failed to parse participants YAML")?;
Ok(parsed)
}
}
fn save(&self) -> Result<()> {
let path = get_participants_file_path()?;
let parent = path.parent().ok_or_else(|| anyhow!("Invalid path"))?;
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory {}", parent.display()))?;
let yaml = serde_yaml::to_string(self)
.with_context(|| "Failed to serialize participants to YAML")?;
fs::write(&path, yaml)
.with_context(|| format!("Failed to write participants file at {}", path.display()))?;
Ok(())
}
}
fn get_participants_file_path() -> Result<PathBuf> {
Ok(crate::config::get_biovault_home()?.join("participants.yaml"))
}
fn check_samtools_installed() -> bool {
Command::new("which")
.arg("samtools")
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
fn detect_reference_version(aligned_file: &str) -> Option<String> {
if !check_samtools_installed() {
return None;
}
let check_ebv = Command::new("sh")
.arg("-c")
.arg(format!(
"samtools view -H {} | grep -c 'SN:chrEBV'",
aligned_file
))
.output();
let check_ki270 = Command::new("sh")
.arg("-c")
.arg(format!(
"samtools view -H {} | grep -c 'SN:KI270'",
aligned_file
))
.output();
match (check_ebv, check_ki270) {
(Ok(ebv), Ok(ki270)) => {
let ebv_count = String::from_utf8_lossy(&ebv.stdout)
.trim()
.parse::<i32>()
.unwrap_or(0);
let ki270_count = String::from_utf8_lossy(&ki270.stdout)
.trim()
.parse::<i32>()
.unwrap_or(0);
if ebv_count > 0 || ki270_count > 0 {
Some("GRCh38".to_string())
} else {
Some("GRCh37".to_string())
}
}
_ => None,
}
}
fn get_index_file_path(file_path: &str, is_aligned: bool) -> String {
if is_aligned {
if file_path.ends_with(".cram") {
format!("{}.crai", file_path)
} else if file_path.ends_with(".bam") {
format!("{}.bai", file_path)
} else if file_path.ends_with(".sam") {
format!("{}.sai", file_path)
} else {
format!("{}.idx", file_path)
}
} else {
format!("{}.fai", file_path)
}
}
fn expand_tilde(path: &str) -> String {
if path.starts_with("~/") {
if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) {
return path.replacen("~", &home, 1);
}
}
path.to_string()
}
fn normalize_path(path: &str) -> Result<String> {
let expanded = expand_tilde(path);
let path_buf = Path::new(&expanded);
if path_buf.is_relative() {
let absolute = std::env::current_dir()?.join(path_buf);
Ok(absolute.to_string_lossy().to_string())
} else {
Ok(expanded)
}
}
pub async fn add(
id: Option<String>,
aligned: Option<String>,
template: Option<String>,
snp: Option<String>,
reference: Option<String>,
ref_version: Option<String>,
non_interactive: bool,
) -> Result<()> {
println!("{}", "Adding new participant...".green().bold());
let template_type = template.as_deref().unwrap_or("default");
if template_type != "default" && template_type != "snp" {
return Err(anyhow!(
"Invalid template type '{}'. Must be 'default' or 'snp'",
template_type
));
}
let mut participants_file = ParticipantsFile::load()?;
let mut index_jobs = Vec::new();
let participant_id = match id {
Some(id) => id,
None => {
if non_interactive {
return Err(anyhow!(
"Participant ID is required in non-interactive mode"
));
}
Input::<String>::new()
.with_prompt("Enter participant ID")
.interact_text()?
}
};
if participants_file.participants.contains_key(&participant_id) {
if non_interactive {
println!("âš Overwriting existing participant '{}'", participant_id);
} else {
let overwrite = Confirm::new()
.with_prompt(format!(
"Participant '{}' already exists. Overwrite?",
participant_id
))
.interact()?;
if !overwrite {
println!("Cancelled.");
return Ok(());
}
}
}
if template_type == "snp" {
let snp_file = match snp {
Some(file) => file,
None => {
if non_interactive {
return Err(anyhow!(
"SNP file path is required for SNP template in non-interactive mode"
));
}
Input::<String>::new()
.with_prompt("Enter SNP file path")
.validate_with(|input: &String| -> Result<(), &str> {
let expanded = expand_tilde(input);
if Path::new(&expanded).exists() {
Ok(())
} else {
Err("File does not exist")
}
})
.interact_text()?
}
};
let snp_file_normalized = normalize_path(&snp_file)?;
if !Path::new(&snp_file_normalized).exists() {
return Err(anyhow!("SNP file does not exist: {}", snp_file_normalized));
}
let participant = Participant {
id: participant_id.clone(),
ref_version: None,
r#ref: None,
ref_index: None,
aligned: None,
aligned_index: None,
snp: Some(snp_file_normalized),
};
participants_file
.participants
.insert(participant_id.clone(), participant);
participants_file.save()?;
println!(
"{}",
format!("✓ SNP Participant '{}' added successfully!", participant_id)
.green()
.bold()
);
return Ok(());
}
if !check_samtools_installed() {
warn!("samtools is not installed. Index creation will not be available.");
println!("{}", "Warning: samtools is not installed. You won't be able to create index files automatically.".yellow());
}
let aligned_file = match aligned {
Some(file) => file,
None => {
if non_interactive {
return Err(anyhow!(
"Aligned file path is required for default template in non-interactive mode"
));
}
Input::<String>::new()
.with_prompt("Enter aligned file path (.cram, .bam, or .sam)")
.validate_with(|input: &String| -> Result<(), &str> {
let expanded = expand_tilde(input);
if Path::new(&expanded).exists() {
Ok(())
} else {
Err("File does not exist")
}
})
.interact_text()?
}
};
let aligned_file = normalize_path(&aligned_file)?;
if !Path::new(&aligned_file).exists() {
return Err(anyhow!("Aligned file does not exist: {}", aligned_file));
}
let aligned_index = get_index_file_path(&aligned_file, true);
let aligned_index_exists = Path::new(&aligned_index).exists();
if aligned_index_exists {
println!("✓ Index file found: {}", aligned_index.green());
} else {
println!("âš Index file not found: {}", aligned_index.yellow());
}
let use_aligned_index = if aligned_index_exists {
if non_interactive {
true } else {
Confirm::new()
.with_prompt(format!("Use this index file: {}?", aligned_index))
.default(true)
.interact()?
}
} else {
false
};
let final_aligned_index = if use_aligned_index {
aligned_index.clone()
} else if non_interactive {
if !Path::new(&aligned_index).exists() && check_samtools_installed() {
index_jobs.push((aligned_file.clone(), true));
}
aligned_index
} else {
let custom_index = Input::<String>::new()
.with_prompt("Enter aligned index file path")
.default(aligned_index.clone())
.interact_text()?;
if !Path::new(&custom_index).exists() && check_samtools_installed() {
let create_index = Confirm::new()
.with_prompt("Index file doesn't exist. Create it after setup?")
.interact()?;
if create_index {
index_jobs.push((aligned_file.clone(), true));
}
}
custom_index
};
let detected_version = detect_reference_version(&aligned_file);
let version_hint = detected_version
.as_ref()
.map(|v| format!(" (Possibly {} detected)", v))
.unwrap_or_default();
let ref_version_str = if let Some(version) = ref_version {
version
} else if non_interactive {
detected_version.unwrap_or_else(|| "GRCh38".to_string())
} else {
let ref_version_choices = vec!["GRCh38", "GRCh37"];
let default_selection = match detected_version.as_deref() {
Some("GRCh38") => 0,
Some("GRCh37") => 1,
_ => 0,
};
let ref_version_idx = Select::new()
.with_prompt(format!("Select reference version{}", version_hint))
.items(&ref_version_choices)
.default(default_selection)
.interact()?;
ref_version_choices[ref_version_idx].to_string()
};
let ref_file = if let Some(ref_path) = reference {
ref_path
} else if non_interactive {
return Err(anyhow!(
"Reference genome file path is required in non-interactive mode"
));
} else {
Input::<String>::new()
.with_prompt("Enter reference genome file path (.fa or .fasta)")
.validate_with(|input: &String| -> Result<(), &str> {
let expanded = expand_tilde(input);
if Path::new(&expanded).exists() {
Ok(())
} else {
Err("File does not exist")
}
})
.interact_text()?
};
let ref_file = normalize_path(&ref_file)?;
let ref_index = get_index_file_path(&ref_file, false);
let ref_index_exists = Path::new(&ref_index).exists();
if ref_index_exists {
println!("✓ Reference index found: {}", ref_index.green());
} else {
println!("âš Reference index not found: {}", ref_index.yellow());
}
let use_ref_index = if ref_index_exists {
if non_interactive {
true } else {
Confirm::new()
.with_prompt(format!("Use this reference index file: {}?", ref_index))
.default(true)
.interact()?
}
} else {
false
};
let final_ref_index = if use_ref_index {
ref_index.clone()
} else if non_interactive {
if !Path::new(&ref_index).exists() && check_samtools_installed() {
index_jobs.push((ref_file.clone(), false));
}
ref_index
} else {
let custom_index = Input::<String>::new()
.with_prompt("Enter reference index file path")
.default(ref_index.clone())
.interact_text()?;
if !Path::new(&custom_index).exists() && check_samtools_installed() {
let create_index = Confirm::new()
.with_prompt("Reference index doesn't exist. Create it after setup?")
.interact()?;
if create_index {
index_jobs.push((ref_file.clone(), false));
}
}
custom_index
};
let participant = Participant {
id: participant_id.clone(),
ref_version: Some(ref_version_str),
r#ref: Some(ref_file.clone()),
ref_index: Some(normalize_path(&final_ref_index)?),
aligned: Some(aligned_file.clone()),
aligned_index: Some(normalize_path(&final_aligned_index)?),
snp: None,
};
participants_file
.participants
.insert(participant_id.clone(), participant);
participants_file.save()?;
println!(
"{}",
format!("✓ Participant '{}' added successfully!", participant_id)
.green()
.bold()
);
if !index_jobs.is_empty() && check_samtools_installed() {
println!("\n{}", "Creating index files...".cyan().bold());
for (file, is_aligned) in index_jobs {
println!("Indexing: {}", file);
let result = if is_aligned {
Command::new("samtools").args(["index", &file]).output()
} else {
Command::new("samtools").args(["faidx", &file]).output()
};
match result {
Ok(output) if output.status.success() => {
println!("✓ Index created successfully");
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
println!("✗ Failed to create index: {}", stderr.red());
}
Err(e) => {
println!("✗ Failed to run samtools: {}", e.to_string().red());
}
}
}
}
Ok(())
}
pub async fn list() -> Result<()> {
let participants_file = ParticipantsFile::load()?;
if participants_file.participants.is_empty() {
println!("{}", "No participants found.".yellow());
println!("Use 'bv participant add' to add a participant.");
return Ok(());
}
println!("{}", "Participants:".green().bold());
println!();
for (id, participant) in &participants_file.participants {
println!(" {} {}", "id:".bold(), id.cyan());
if let Some(ref snp) = participant.snp {
println!(" type: SNP");
println!(" snp: {}", snp);
} else {
println!(" type: CRAM/BAM");
if let Some(ref ref_version) = participant.ref_version {
println!(" ref_version: {}", ref_version);
}
if let Some(ref r#ref) = participant.r#ref {
println!(" ref: {}", r#ref);
}
if let Some(ref ref_index) = participant.ref_index {
println!(" ref_index: {}", ref_index);
}
if let Some(ref aligned) = participant.aligned {
println!(" aligned: {}", aligned);
}
if let Some(ref aligned_index) = participant.aligned_index {
println!(" aligned_index: {}", aligned_index);
}
}
println!();
}
Ok(())
}
pub async fn delete(id: String) -> Result<()> {
let mut participants_file = ParticipantsFile::load()?;
if !participants_file.participants.contains_key(&id) {
return Err(anyhow!("Participant '{}' not found", id));
}
let confirm = Confirm::new()
.with_prompt(format!("Delete participant '{}'?", id))
.interact()?;
if !confirm {
println!("Cancelled.");
return Ok(());
}
participants_file.participants.remove(&id);
participants_file.save()?;
println!(
"{}",
format!("✓ Participant '{}' deleted successfully!", id)
.green()
.bold()
);
Ok(())
}
pub async fn validate(id: Option<String>) -> Result<()> {
if !check_samtools_installed() {
return Err(anyhow!(
"samtools is not installed. Please install samtools first."
));
}
let check_seqkit = Command::new("which")
.arg("seqkit")
.output()
.map(|output| output.status.success())
.unwrap_or(false);
if !check_seqkit {
return Err(anyhow!(
"seqkit is not installed. Please install seqkit first."
));
}
let participants_file = ParticipantsFile::load()?;
let participants_to_validate: Vec<(String, Participant)> = match id {
Some(ref id) => {
let participant = participants_file
.participants
.get(id)
.ok_or_else(|| anyhow!("Participant '{}' not found", id))?;
vec![(id.clone(), participant.clone())]
}
None => participants_file.participants.into_iter().collect(),
};
if participants_to_validate.is_empty() {
println!("{}", "No participants to validate.".yellow());
return Ok(());
}
println!("{}", "Validating participants...".green().bold());
println!();
let mut all_valid = true;
for (id, participant) in participants_to_validate {
println!("Validating participant: {}", id.cyan());
if let Some(snp) = &participant.snp {
if Path::new(snp).exists() {
println!(" ✓ SNP file exists: {}", snp.green());
} else {
println!(" ✗ SNP file not found: {}", snp.red());
all_valid = false;
}
continue; }
if let Some(aligned) = &participant.aligned {
let aligned_result = Command::new("samtools")
.args(["quickcheck", "-v", aligned])
.output();
match aligned_result {
Ok(output) if output.status.success() => {
println!(" ✓ Aligned file is valid: {}", aligned.green());
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
println!(" ✗ Aligned file is invalid: {}", aligned.red());
if !stderr.is_empty() {
println!(" Error: {}", stderr.trim());
}
all_valid = false;
}
Err(e) => {
println!(
" ✗ Failed to validate aligned file: {}",
e.to_string().red()
);
all_valid = false;
}
}
}
if let Some(r#ref) = &participant.r#ref {
let ref_result = Command::new("seqkit").args(["stats", r#ref]).output();
match ref_result {
Ok(output) if output.status.success() => {
println!(" ✓ Reference file is valid: {}", r#ref.green());
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.is_empty() {
for line in stdout.lines().skip(1).take(1) {
println!(" {}", line.trim());
}
}
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
println!(" ✗ Reference file is invalid: {}", r#ref.red());
if !stderr.is_empty() {
println!(" Error: {}", stderr.trim());
}
all_valid = false;
}
Err(e) => {
println!(
" ✗ Failed to validate reference file: {}",
e.to_string().red()
);
all_valid = false;
}
}
}
if let Some(aligned_index) = &participant.aligned_index {
if !Path::new(aligned_index).exists() {
println!(
" âš Aligned index file not found: {}",
aligned_index.yellow()
);
} else {
println!(" ✓ Aligned index file exists: {}", aligned_index.green());
}
}
if let Some(ref_index) = &participant.ref_index {
if !Path::new(ref_index).exists() {
println!(" âš Reference index file not found: {}", ref_index.yellow());
} else {
println!(" ✓ Reference index file exists: {}", ref_index.green());
}
}
println!();
}
if all_valid {
println!("{}", "✓ All validations passed!".green().bold());
} else {
println!("{}", "✗ Some validations failed.".red().bold());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn index_file_path_rules() {
assert_eq!(get_index_file_path("x.cram", true), "x.cram.crai");
assert_eq!(get_index_file_path("x.bam", true), "x.bam.bai");
assert_eq!(get_index_file_path("x.sam", true), "x.sam.sai");
assert_eq!(get_index_file_path("x.xyz", true), "x.xyz.idx");
assert_eq!(get_index_file_path("ref.fa", false), "ref.fa.fai");
}
#[test]
#[serial_test::serial]
#[cfg(unix)]
fn expand_tilde_resolves_home_unix() {
let tmp = TempDir::new().unwrap();
std::env::set_var("HOME", tmp.path());
let expanded = super::expand_tilde("~/file.txt");
assert_eq!(
std::path::Path::new(&expanded),
&tmp.path().join("file.txt")
);
}
#[test]
#[serial_test::serial]
#[cfg(windows)]
fn expand_tilde_resolves_home_windows() {
let tmp = TempDir::new().unwrap();
std::env::set_var("USERPROFILE", tmp.path());
let expanded = super::expand_tilde("~/file.txt");
assert_eq!(
std::path::Path::new(&expanded),
&tmp.path().join("file.txt")
);
}
#[test]
#[serial_test::serial]
fn normalize_path_makes_absolute_and_keeps_tail() {
let tmp = TempDir::new().unwrap();
let cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(tmp.path()).unwrap();
let abs = super::normalize_path("rel/path").unwrap();
let p = std::path::Path::new(&abs);
assert!(p.is_absolute());
assert!(p.ends_with(std::path::Path::new("rel").join("path")));
std::env::set_current_dir(cwd).unwrap();
}
#[test]
fn test_participant_serialize_deserialize() {
let participant = Participant {
id: "test123".to_string(),
ref_version: Some("GRCh38".to_string()),
r#ref: Some("/path/to/ref.fa".to_string()),
ref_index: Some("/path/to/ref.fa.fai".to_string()),
aligned: Some("/path/to/aligned.cram".to_string()),
aligned_index: Some("/path/to/aligned.cram.crai".to_string()),
snp: None,
};
let serialized = serde_yaml::to_string(&participant).unwrap();
assert!(serialized.contains("id: test123"));
assert!(serialized.contains("ref_version: GRCh38"));
assert!(serialized.contains("ref: /path/to/ref.fa"));
let deserialized: Participant = serde_yaml::from_str(&serialized).unwrap();
assert_eq!(deserialized.id, "test123");
assert_eq!(deserialized.ref_version, Some("GRCh38".to_string()));
}
#[test]
fn test_participant_snp_variant() {
let participant = Participant {
id: "snp_test".to_string(),
ref_version: None,
r#ref: None,
ref_index: None,
aligned: None,
aligned_index: None,
snp: Some("/path/to/snp.vcf".to_string()),
};
assert_eq!(participant.id, "snp_test");
assert_eq!(participant.snp, Some("/path/to/snp.vcf".to_string()));
assert!(participant.aligned.is_none());
assert!(participant.ref_version.is_none());
}
#[test]
fn test_participants_file_default() {
let pf = ParticipantsFile::default();
assert!(pf.participants.is_empty());
}
#[test]
fn test_participants_file_with_multiple_entries() {
let mut pf = ParticipantsFile::new();
let p1 = Participant {
id: "p1".to_string(),
ref_version: Some("GRCh37".to_string()),
r#ref: None,
ref_index: None,
aligned: None,
aligned_index: None,
snp: None,
};
let p2 = Participant {
id: "p2".to_string(),
ref_version: Some("GRCh38".to_string()),
r#ref: None,
ref_index: None,
aligned: None,
aligned_index: None,
snp: None,
};
pf.participants.insert("p1".to_string(), p1.clone());
pf.participants.insert("p2".to_string(), p2.clone());
assert_eq!(pf.participants.len(), 2);
assert!(pf.participants.contains_key("p1"));
assert!(pf.participants.contains_key("p2"));
assert_eq!(
pf.participants["p1"].ref_version,
Some("GRCh37".to_string())
);
assert_eq!(
pf.participants["p2"].ref_version,
Some("GRCh38".to_string())
);
}
#[test]
#[serial_test::serial]
fn test_participants_file_load_nonexistent() {
let temp = TempDir::new().unwrap();
std::env::set_var("BIOVAULT_HOME", temp.path());
let pf = ParticipantsFile::load().unwrap();
assert!(pf.participants.is_empty());
std::env::remove_var("BIOVAULT_HOME");
}
#[test]
#[serial_test::serial]
fn test_participants_file_save_and_reload() {
let temp = TempDir::new().unwrap();
std::env::set_var("BIOVAULT_HOME", temp.path());
let mut pf = ParticipantsFile::new();
let participant = Participant {
id: "save_test".to_string(),
ref_version: Some("GRCh38".to_string()),
r#ref: Some("/ref.fa".to_string()),
ref_index: Some("/ref.fa.fai".to_string()),
aligned: Some("/aligned.cram".to_string()),
aligned_index: Some("/aligned.cram.crai".to_string()),
snp: None,
};
pf.participants.insert("save_test".to_string(), participant);
pf.save().unwrap();
let file_path = temp.path().join("participants.yaml");
assert!(file_path.exists());
let loaded = ParticipantsFile::load().unwrap();
assert_eq!(loaded.participants.len(), 1);
assert!(loaded.participants.contains_key("save_test"));
assert_eq!(loaded.participants["save_test"].id, "save_test");
assert_eq!(
loaded.participants["save_test"].ref_version,
Some("GRCh38".to_string())
);
std::env::remove_var("BIOVAULT_HOME");
}
#[test]
fn test_get_index_file_path_edge_cases() {
assert_eq!(get_index_file_path("test.CRAM", true), "test.CRAM.idx"); assert_eq!(get_index_file_path("test.BAM", true), "test.BAM.idx"); assert_eq!(get_index_file_path("test.SAM", true), "test.SAM.idx"); assert_eq!(get_index_file_path("test", true), "test.idx");
assert_eq!(get_index_file_path("test.txt", true), "test.txt.idx");
assert_eq!(get_index_file_path("test.cram", true), "test.cram.crai");
assert_eq!(get_index_file_path("test.bam", true), "test.bam.bai");
assert_eq!(get_index_file_path("test.sam", true), "test.sam.sai");
assert_eq!(
get_index_file_path("genome.fasta", false),
"genome.fasta.fai"
);
assert_eq!(get_index_file_path("genome.FA", false), "genome.FA.fai");
assert_eq!(get_index_file_path("genome", false), "genome.fai");
}
#[test]
fn test_expand_tilde_edge_cases() {
assert_eq!(expand_tilde(""), "");
assert_eq!(expand_tilde("~"), "~");
assert_eq!(expand_tilde("~user/file"), "~user/file"); assert_eq!(expand_tilde("/~/file"), "/~/file"); assert_eq!(expand_tilde("./~/file"), "./~/file"); }
#[test]
#[serial_test::serial]
fn test_normalize_path_with_tilde() {
let temp = TempDir::new().unwrap();
let temp_path = temp.path().to_string_lossy().to_string();
if cfg!(unix) {
std::env::set_var("HOME", &temp_path);
} else {
std::env::set_var("USERPROFILE", &temp_path);
}
let result = normalize_path("~/test.txt").unwrap();
assert!(result.contains("test.txt"));
assert!(std::path::Path::new(&result).is_absolute());
}
#[test]
fn test_participant_clone() {
let original = Participant {
id: "clone_test".to_string(),
ref_version: Some("GRCh38".to_string()),
r#ref: Some("/ref.fa".to_string()),
ref_index: None,
aligned: None,
aligned_index: None,
snp: None,
};
let cloned = original.clone();
assert_eq!(cloned.id, original.id);
assert_eq!(cloned.ref_version, original.ref_version);
assert_eq!(cloned.r#ref, original.r#ref);
}
#[test]
fn test_participant_debug_format() {
let participant = Participant {
id: "debug_test".to_string(),
ref_version: None,
r#ref: None,
ref_index: None,
aligned: None,
aligned_index: None,
snp: Some("/snp.vcf".to_string()),
};
let debug_str = format!("{:?}", participant);
assert!(debug_str.contains("debug_test"));
assert!(debug_str.contains("snp"));
assert!(debug_str.contains("/snp.vcf"));
}
#[test]
#[serial_test::serial]
fn test_get_participants_file_path() {
let temp = TempDir::new().unwrap();
let home = temp.path().join("biovault-home");
std::env::set_var("BIOVAULT_TEST_HOME", &home);
let path = get_participants_file_path().unwrap();
assert!(path.ends_with("participants.yaml"));
assert!(path.starts_with(&home));
std::env::remove_var("BIOVAULT_TEST_HOME");
}
#[test]
fn test_check_samtools_installed() {
let _result = check_samtools_installed();
}
#[test]
fn test_detect_reference_version_without_samtools() {
let result = detect_reference_version("/nonexistent/file.bam");
assert!(result.is_none() || result.is_some());
}
#[tokio::test]
async fn test_list_empty_participants() {
let temp = TempDir::new().unwrap();
std::env::set_var("BIOVAULT_HOME", temp.path());
let result = list().await;
assert!(result.is_ok());
std::env::remove_var("BIOVAULT_HOME");
}
#[tokio::test]
#[serial_test::serial]
async fn test_list_with_participants() {
let temp = TempDir::new().unwrap();
std::env::set_var("BIOVAULT_HOME", temp.path());
let mut pf = ParticipantsFile::new();
pf.participants.insert(
"test_id".to_string(),
Participant {
id: "test_id".to_string(),
ref_version: Some("GRCh38".to_string()),
r#ref: Some("/path/ref.fa".to_string()),
ref_index: None,
aligned: None,
aligned_index: None,
snp: None,
},
);
pf.save().unwrap();
let result = list().await;
assert!(result.is_ok());
std::env::remove_var("BIOVAULT_HOME");
}
#[tokio::test]
async fn test_delete_nonexistent_participant() {
let temp = TempDir::new().unwrap();
std::env::set_var("BIOVAULT_HOME", temp.path());
let result = delete("nonexistent".to_string()).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not found"));
std::env::remove_var("BIOVAULT_HOME");
}
#[test]
fn test_participants_file_new() {
let pf = ParticipantsFile::new();
assert!(pf.participants.is_empty());
}
#[test]
fn test_participant_partial_data() {
let p = Participant {
id: "partial".to_string(),
ref_version: Some("GRCh38".to_string()),
r#ref: None,
ref_index: None,
aligned: Some("/aligned.bam".to_string()),
aligned_index: None,
snp: None,
};
assert_eq!(p.id, "partial");
assert!(p.ref_version.is_some());
assert!(p.r#ref.is_none());
assert!(p.aligned.is_some());
}
#[test]
fn test_get_index_file_path_lowercase_variants() {
assert_eq!(get_index_file_path("file.cram", true), "file.cram.crai");
assert_eq!(get_index_file_path("file.bam", true), "file.bam.bai");
assert_eq!(get_index_file_path("file.sam", true), "file.sam.sai");
assert_eq!(get_index_file_path("ref.fasta", false), "ref.fasta.fai");
}
#[test]
fn test_expand_tilde_no_expansion() {
assert_eq!(expand_tilde("/absolute/path"), "/absolute/path");
assert_eq!(expand_tilde("relative/path"), "relative/path");
assert_eq!(expand_tilde(""), "");
}
#[test]
#[cfg(unix)]
fn test_normalize_path_absolute() {
let result = normalize_path("/absolute/path");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "/absolute/path");
}
#[test]
#[cfg(windows)]
fn test_normalize_path_absolute() {
let result = normalize_path("C:\\absolute\\path");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "C:\\absolute\\path");
}
}