use std::collections::BTreeMap;
use std::fmt;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct BoulderRecord {
entries: BTreeMap<String, String>,
}
impl BoulderRecord {
pub fn new() -> Self {
Self::default()
}
pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.entries.insert(key.into(), value.into());
}
pub fn get(&self, key: &str) -> Option<&str> {
self.entries.get(key).map(String::as_str)
}
pub fn remove(&mut self, key: &str) -> Option<String> {
self.entries.remove(key)
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
self.entries.iter().map(|(k, v)| (k.as_str(), v.as_str()))
}
}
impl fmt::Display for BoulderRecord {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", format_boulder(self))
}
}
pub fn parse_boulder(text: &str) -> Vec<BoulderRecord> {
let mut records = Vec::new();
let mut current = BoulderRecord::new();
for line in text.lines() {
let line = line.trim_end();
if line == "=" {
if !current.is_empty() {
records.push(current);
current = BoulderRecord::new();
}
continue;
}
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
current.set(key, value);
}
}
if !current.is_empty() {
records.push(current);
}
records
}
pub fn format_boulder(record: &BoulderRecord) -> String {
let mut out = String::new();
for (key, value) in record.iter() {
out.push_str(key);
out.push('=');
out.push_str(value);
out.push('\n');
}
out.push_str("=\n");
out
}
pub fn sequence_args_from_boulder(
record: &BoulderRecord,
) -> crate::error::Result<crate::SequenceArgs> {
let mut builder = crate::SequenceArgs::builder();
if let Some(id) = record.get("SEQUENCE_ID") {
builder = builder.sequence_id(id);
}
let template = record.get("SEQUENCE_TEMPLATE").ok_or_else(|| {
crate::error::Primer3Error::InvalidSetting("SEQUENCE_TEMPLATE is required".into())
})?;
builder = builder.sequence(template);
if let Some(val) = record.get("SEQUENCE_TARGET") {
for pair in val.split(';') {
let pair = pair.trim();
if pair.is_empty() {
continue;
}
if let Some((start, len)) = pair.split_once(',') {
let start: usize = start.trim().parse().map_err(|_| {
crate::error::Primer3Error::InvalidSetting(format!(
"invalid SEQUENCE_TARGET start: {start}"
))
})?;
let len: usize = len.trim().parse().map_err(|_| {
crate::error::Primer3Error::InvalidSetting(format!(
"invalid SEQUENCE_TARGET length: {len}"
))
})?;
builder = builder.target(start, len);
}
}
}
if let Some(val) = record.get("SEQUENCE_EXCLUDED_REGION") {
for pair in val.split(';') {
let pair = pair.trim();
if pair.is_empty() {
continue;
}
if let Some((start, len)) = pair.split_once(',') {
let start: usize = start.trim().parse().map_err(|_| {
crate::error::Primer3Error::InvalidSetting(format!(
"invalid SEQUENCE_EXCLUDED_REGION start: {start}"
))
})?;
let len: usize = len.trim().parse().map_err(|_| {
crate::error::Primer3Error::InvalidSetting(format!(
"invalid SEQUENCE_EXCLUDED_REGION length: {len}"
))
})?;
builder = builder.excluded_region(start, len);
}
}
}
if let Some(val) = record.get("SEQUENCE_INCLUDED_REGION") {
if let Some((start, len)) = val.split_once(',') {
let start: usize = start.trim().parse().map_err(|_| {
crate::error::Primer3Error::InvalidSetting(
"invalid SEQUENCE_INCLUDED_REGION start".into(),
)
})?;
let len: usize = len.trim().parse().map_err(|_| {
crate::error::Primer3Error::InvalidSetting(
"invalid SEQUENCE_INCLUDED_REGION length".into(),
)
})?;
builder = builder.included_region(start, len);
}
}
if let Some(val) = record.get("SEQUENCE_PRIMER") {
builder = builder.left_primer(val);
}
if let Some(val) = record.get("SEQUENCE_PRIMER_REVCOMP") {
builder = builder.right_primer(val);
}
if let Some(val) = record.get("SEQUENCE_INTERNAL_OLIGO") {
builder = builder.internal_oligo(val);
}
builder.build()
}
pub fn primer_settings_from_boulder(
record: &BoulderRecord,
) -> crate::error::Result<crate::PrimerSettings> {
let mut builder = crate::PrimerSettings::builder();
if let Some(val) = record.get("PRIMER_TASK") {
builder = builder.task(match val {
"generic" => crate::PrimerTask::Generic,
"check_primers" => crate::PrimerTask::CheckPrimers,
"pick_pcr_primers" | "pick_detection_primers" => crate::PrimerTask::PickPcrPrimers,
"pick_pcr_primers_and_hyb_probe" => crate::PrimerTask::PickPcrPrimersAndHybProbe,
"pick_left_only" => crate::PrimerTask::PickLeftOnly,
"pick_right_only" => crate::PrimerTask::PickRightOnly,
"pick_hyb_probe_only" | "pick_internal_oligo_only" => {
crate::PrimerTask::PickHybProbeOnly
}
"pick_cloning_primers" => crate::PrimerTask::PickCloningPrimers,
"pick_discriminative_primers" => crate::PrimerTask::PickDiscriminativePrimers,
"pick_sequencing_primers" => crate::PrimerTask::PickSequencingPrimers,
"pick_primer_list" => crate::PrimerTask::PickPrimerList,
_ => {
return Err(crate::error::Primer3Error::InvalidSetting(format!(
"unknown PRIMER_TASK: {val}"
)));
}
});
}
if let Some(val) = record.get("PRIMER_OPT_TM") {
builder = builder.primer_opt_tm(parse_f64(val, "PRIMER_OPT_TM")?);
}
if let Some(val) = record.get("PRIMER_MIN_TM") {
builder = builder.primer_min_tm(parse_f64(val, "PRIMER_MIN_TM")?);
}
if let Some(val) = record.get("PRIMER_MAX_TM") {
builder = builder.primer_max_tm(parse_f64(val, "PRIMER_MAX_TM")?);
}
if let Some(val) = record.get("PRIMER_OPT_SIZE") {
builder = builder.primer_opt_size(parse_usize(val, "PRIMER_OPT_SIZE")?);
}
if let Some(val) = record.get("PRIMER_MIN_SIZE") {
builder = builder.primer_min_size(parse_usize(val, "PRIMER_MIN_SIZE")?);
}
if let Some(val) = record.get("PRIMER_MAX_SIZE") {
builder = builder.primer_max_size(parse_usize(val, "PRIMER_MAX_SIZE")?);
}
if let Some(val) = record.get("PRIMER_NUM_RETURN") {
builder = builder.num_return(parse_usize(val, "PRIMER_NUM_RETURN")?);
}
if let Some(val) = record.get("PRIMER_MAX_DIFF_TM") {
builder = builder.max_diff_tm(parse_f64(val, "PRIMER_MAX_DIFF_TM")?);
}
if let Some(val) = record.get("PRIMER_MIN_GC") {
builder = builder.primer_min_gc(parse_f64(val, "PRIMER_MIN_GC")?);
}
if let Some(val) = record.get("PRIMER_MAX_GC") {
builder = builder.primer_max_gc(parse_f64(val, "PRIMER_MAX_GC")?);
}
if let Some(val) = record.get("PRIMER_PRODUCT_SIZE_RANGE") {
for range_str in val.split(';') {
let range_str = range_str.trim();
if range_str.is_empty() {
continue;
}
if let Some((min_s, max_s)) = range_str.split_once('-') {
let min = parse_usize(min_s.trim(), "PRIMER_PRODUCT_SIZE_RANGE min")?;
let max = parse_usize(max_s.trim(), "PRIMER_PRODUCT_SIZE_RANGE max")?;
builder = builder.product_size_range(min, max);
}
}
}
builder.build()
}
pub fn design_result_to_boulder(result: &crate::DesignResult) -> BoulderRecord {
let mut rec = BoulderRecord::new();
rec.set("PRIMER_PAIR_NUM_RETURNED", result.num_pairs().to_string());
rec.set("PRIMER_LEFT_NUM_RETURNED", result.left_primers().len().to_string());
rec.set("PRIMER_RIGHT_NUM_RETURNED", result.right_primers().len().to_string());
rec.set("PRIMER_INTERNAL_NUM_RETURNED", result.internal_oligos().len().to_string());
if let Some(w) = result.warnings() {
rec.set("PRIMER_WARNING", w.to_string());
}
for (i, pair) in result.pairs().iter().enumerate() {
let left = pair.left();
let right = pair.right();
rec.set(format!("PRIMER_LEFT_{i}_SEQUENCE"), left.sequence().to_string());
rec.set(format!("PRIMER_RIGHT_{i}_SEQUENCE"), right.sequence().to_string());
rec.set(format!("PRIMER_LEFT_{i}"), format!("{},{}", left.start(), left.length()));
rec.set(format!("PRIMER_RIGHT_{i}"), format!("{},{}", right.start(), right.length()));
rec.set(format!("PRIMER_LEFT_{i}_TM"), format!("{:.3}", left.tm()));
rec.set(format!("PRIMER_RIGHT_{i}_TM"), format!("{:.3}", right.tm()));
rec.set(format!("PRIMER_LEFT_{i}_GC_PERCENT"), format!("{:.3}", left.gc_content()));
rec.set(format!("PRIMER_RIGHT_{i}_GC_PERCENT"), format!("{:.3}", right.gc_content()));
rec.set(format!("PRIMER_LEFT_{i}_PENALTY"), format!("{:.6}", left.penalty()));
rec.set(format!("PRIMER_RIGHT_{i}_PENALTY"), format!("{:.6}", right.penalty()));
rec.set(format!("PRIMER_PAIR_{i}_PENALTY"), format!("{:.6}", pair.pair_penalty()));
rec.set(format!("PRIMER_PAIR_{i}_PRODUCT_SIZE"), pair.product_size().to_string());
if let Some(internal) = pair.internal() {
rec.set(format!("PRIMER_INTERNAL_{i}_SEQUENCE"), internal.sequence().to_string());
rec.set(
format!("PRIMER_INTERNAL_{i}"),
format!("{},{}", internal.start(), internal.length()),
);
rec.set(format!("PRIMER_INTERNAL_{i}_TM"), format!("{:.3}", internal.tm()));
}
}
rec
}
fn parse_f64(val: &str, tag: &str) -> crate::error::Result<f64> {
val.trim().parse().map_err(|_| {
crate::error::Primer3Error::InvalidSetting(format!("invalid {tag} value: {val}"))
})
}
fn parse_usize(val: &str, tag: &str) -> crate::error::Result<usize> {
val.trim().parse().map_err(|_| {
crate::error::Primer3Error::InvalidSetting(format!("invalid {tag} value: {val}"))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_single_record() {
let input = "SEQUENCE_ID=test\nSEQUENCE_TEMPLATE=ATCGATCG\n=\n";
let records = parse_boulder(input);
assert_eq!(records.len(), 1);
assert_eq!(records[0].get("SEQUENCE_ID"), Some("test"));
assert_eq!(records[0].get("SEQUENCE_TEMPLATE"), Some("ATCGATCG"));
}
#[test]
fn test_parse_multiple_records() {
let input = "KEY1=val1\n=\nKEY2=val2\n=\n";
let records = parse_boulder(input);
assert_eq!(records.len(), 2);
assert_eq!(records[0].get("KEY1"), Some("val1"));
assert_eq!(records[1].get("KEY2"), Some("val2"));
}
#[test]
fn test_parse_skips_comments_and_blanks() {
let input = "# comment\n\nKEY=val\n=\n";
let records = parse_boulder(input);
assert_eq!(records.len(), 1);
assert_eq!(records[0].get("KEY"), Some("val"));
}
#[test]
fn test_parse_no_terminator() {
let input = "KEY=val";
let records = parse_boulder(input);
assert_eq!(records.len(), 1);
}
#[test]
fn test_format_boulder_roundtrip() {
let mut rec = BoulderRecord::new();
rec.set("A", "1");
rec.set("B", "2");
let text = format_boulder(&rec);
assert!(text.contains("A=1\n"));
assert!(text.contains("B=2\n"));
assert!(text.ends_with("=\n"));
let parsed = parse_boulder(&text);
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0], rec);
}
#[test]
fn test_sequence_args_from_boulder() {
let mut rec = BoulderRecord::new();
rec.set("SEQUENCE_ID", "test_gene");
rec.set("SEQUENCE_TEMPLATE", "ATCGATCGATCGATCGATCG");
rec.set("SEQUENCE_TARGET", "5,10");
let args = sequence_args_from_boulder(&rec).unwrap();
assert_eq!(args.sequence_id.as_deref(), Some("test_gene"));
assert_eq!(args.sequence, "ATCGATCGATCGATCGATCG");
assert_eq!(args.targets.len(), 1);
assert_eq!(args.targets[0].start, 5);
assert_eq!(args.targets[0].length, 10);
}
#[test]
fn test_sequence_args_missing_template() {
let rec = BoulderRecord::new();
assert!(sequence_args_from_boulder(&rec).is_err());
}
#[test]
fn test_primer_settings_from_boulder() {
let mut rec = BoulderRecord::new();
rec.set("PRIMER_OPT_TM", "60.0");
rec.set("PRIMER_MIN_TM", "57.0");
rec.set("PRIMER_MAX_TM", "63.0");
rec.set("PRIMER_PRODUCT_SIZE_RANGE", "75-300");
rec.set("PRIMER_NUM_RETURN", "10");
let settings = primer_settings_from_boulder(&rec).unwrap();
assert!((settings.primer.opt_tm - 60.0).abs() < f64::EPSILON);
assert_eq!(settings.num_return, 10);
assert_eq!(settings.product_size_ranges, vec![(75, 300)]);
}
#[test]
fn test_primer_settings_task_parsing() {
let mut rec = BoulderRecord::new();
rec.set("PRIMER_TASK", "check_primers");
let settings = primer_settings_from_boulder(&rec).unwrap();
assert_eq!(settings.task, crate::PrimerTask::CheckPrimers);
}
#[test]
fn test_boulder_record_display() {
let mut rec = BoulderRecord::new();
rec.set("KEY", "VALUE");
let s = rec.to_string();
assert!(s.contains("KEY=VALUE"));
}
}