primer3 0.1.0

Safe Rust bindings to the primer3 primer design library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
//! Primer design settings (global arguments).
//!
//! [`PrimerSettings`] controls the constraints and parameters for primer design,
//! equivalent to the `global_args` dictionary in primer3-py.
//!
//! All defaults match the primer3 C library v2 defaults
//! (`p3_create_global_settings()`). Note that some defaults differ from
//! primer3-py.

use crate::conditions::SolutionConditions;
use crate::error::{Primer3Error, Result};
use crate::tm::{SaltCorrectionMethod, TmMethod};
use crate::weights::{OligoWeights, PairWeights};

/// The task that primer3 should perform.
///
/// Maps to the C enum `task` in `libprimer3.h`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum PrimerTask {
    /// Design PCR primer pairs (default).
    #[default]
    PickPcrPrimers,
    /// Design PCR primers and a hybridization probe.
    PickPcrPrimersAndHybProbe,
    /// Design left primer only.
    PickLeftOnly,
    /// Design right primer only.
    PickRightOnly,
    /// Design internal hybridization probe only.
    PickHybProbeOnly,
    /// Generic primer picking (fully controlled by pick left/right/internal flags).
    Generic,
    /// Design cloning primers.
    PickCloningPrimers,
    /// Design discriminative primers.
    PickDiscriminativePrimers,
    /// Design sequencing primers (uses sequencing params).
    PickSequencingPrimers,
    /// Return a list of all acceptable primers (no pairing).
    PickPrimerList,
    /// Check supplied primers against constraints (no design).
    CheckPrimers,
}

/// Parameters for sequencing primer design.
///
/// Only used when `PrimerTask::PickSequencingPrimers` is selected.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SequencingParams {
    /// Number of bases between the 3' end of the primer and the start of
    /// readable sequence (default: 50).
    pub lead: usize,
    /// Optimal spacing between sequencing primers (default: 500).
    pub spacing: usize,
    /// Acceptable range around the optimal spacing (default: 250).
    pub interval: usize,
    /// Number of bases that must be covered by reads from both
    /// directions (default: 20).
    pub accuracy: usize,
}

impl Default for SequencingParams {
    fn default() -> Self {
        Self { lead: 50, spacing: 500, interval: 250, accuracy: 20 }
    }
}

/// Settings for an individual primer or internal oligo.
///
/// Controls constraints on size, Tm, GC content, self-complementarity, etc.
///
/// **Primer-only fields** (ignored for internal oligos):
/// `max_end_stability`, `max_template_mispriming`, `max_template_mispriming_th`
///
/// **Internal-oligo-only fields** (ignored for primers):
/// none currently -- all fields apply to both, though some constraints may
/// not be meaningful for probes (e.g., `max_poly_x` is often relaxed).
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct OligoSettings {
    // Size constraints
    /// Optimal primer length in bp (default: 20).
    pub opt_size: usize,
    /// Minimum primer length in bp (default: 18).
    pub min_size: usize,
    /// Maximum primer length in bp (default: 27).
    /// Note: primer3-py defaults to 25; we follow the C library v2 default of 27.
    pub max_size: usize,

    // Tm constraints
    /// Optimal melting temperature in Celsius (default: 60.0).
    pub opt_tm: f64,
    /// Minimum melting temperature in Celsius (default: 57.0).
    pub min_tm: f64,
    /// Maximum melting temperature in Celsius (default: 63.0).
    pub max_tm: f64,

    // Bound fraction constraints (at annealing temperature)
    /// Optimal fraction of primers bound at annealing temp (default: 0.0, unset).
    pub opt_bound: f64,
    /// Minimum bound fraction (default: 0.0).
    pub min_bound: f64,
    /// Maximum bound fraction (default: 0.0, unset).
    pub max_bound: f64,

    // GC content constraints
    /// Optimal GC content as percentage (default: 50.0).
    pub opt_gc_content: f64,
    /// Minimum GC content as percentage (default: 20.0).
    pub min_gc: f64,
    /// Maximum GC content as percentage (default: 80.0).
    pub max_gc: f64,

    // Salt/buffer concentrations
    /// Salt and buffer concentrations for Tm calculations.
    pub conditions: SolutionConditions,

    // Complementarity constraints
    /// Maximum self-complementarity score (default: 8.0).
    pub max_self_any: f64,
    /// Maximum 3' self-complementarity score (default: 3.0).
    pub max_self_end: f64,
    /// Maximum self-complementarity (thermodynamic) in cal/mol (default: 47.0).
    pub max_self_any_th: f64,
    /// Maximum 3' self-complementarity (thermodynamic) in cal/mol (default: 47.0).
    pub max_self_end_th: f64,
    /// Maximum hairpin stability (thermodynamic) in cal/mol (default: 47.0).
    pub max_hairpin_th: f64,
    /// Maximum repeat library complementarity (default: 12.0).
    pub max_repeat_compl: f64,

    // Template mispriming (primer-only -- ignored for internal oligos)
    /// Maximum template mispriming score (default: 12.0).
    pub max_template_mispriming: f64,
    /// Maximum template mispriming (thermodynamic) (default: 47.0).
    pub max_template_mispriming_th: f64,

    // Sequence matching constraints
    /// IUPAC pattern that the 5' end of the primer must match (e.g., "NNNNN").
    /// Length must be exactly 5 characters. `None` means no constraint.
    pub must_match_five_prime: Option<String>,
    /// IUPAC pattern that the 3' end of the primer must match.
    /// Length must be exactly 5 characters. `None` means no constraint.
    pub must_match_three_prime: Option<String>,

    // Other
    /// Maximum number of Ns accepted in a primer (default: 0).
    pub num_ns_accepted: usize,
    /// Maximum homopolymer length (default: 5).
    pub max_poly_x: usize,

    /// Penalty weights for scoring this oligo type.
    pub weights: OligoWeights,
}

impl Default for OligoSettings {
    fn default() -> Self {
        Self {
            opt_size: 20,
            min_size: 18,
            max_size: 27,
            opt_tm: 60.0,
            min_tm: 57.0,
            max_tm: 63.0,
            opt_bound: 0.0,
            min_bound: 0.0,
            max_bound: 0.0,
            opt_gc_content: 50.0,
            min_gc: 20.0,
            max_gc: 80.0,
            conditions: SolutionConditions::default(),
            max_self_any: 8.0,
            max_self_end: 3.0,
            max_self_any_th: 47.0,
            max_self_end_th: 47.0,
            max_hairpin_th: 47.0,
            max_repeat_compl: 12.0,
            max_template_mispriming: 12.0,
            max_template_mispriming_th: 47.0,
            must_match_five_prime: None,
            must_match_three_prime: None,
            num_ns_accepted: 0,
            max_poly_x: 5,
            weights: OligoWeights::default(),
        }
    }
}

/// Global settings for primer design.
///
/// Controls the design task, product size ranges, Tm method, pair
/// complementarity limits, and per-oligo constraints.
///
/// Uses primer3 C library v2 defaults (`p3_create_global_settings()`).
///
/// Construct with [`PrimerSettings::builder()`] or [`PrimerSettings::default()`].
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[allow(clippy::struct_excessive_bools)]
pub struct PrimerSettings {
    /// The design task to perform (default: `PickPcrPrimers`).
    pub task: PrimerTask,

    /// Whether to design a left primer (default: true).
    pub pick_left_primer: bool,
    /// Whether to design a right primer (default: true).
    pub pick_right_primer: bool,
    /// Whether to design an internal oligo (default: false).
    pub pick_internal_oligo: bool,

    /// Number of primer pairs to return (default: 5).
    pub num_return: usize,
    /// Pick primers even if they violate constraints (default: false).
    pub pick_anyway: bool,

    // Tm calculation method
    /// Tm calculation method (default: `SantaLucia` 1998).
    pub tm_method: TmMethod,
    /// Salt correction method (default: `SantaLucia` 1998).
    pub salt_correction_method: SaltCorrectionMethod,
    /// PCR annealing temperature in Celsius (default: -10.0, meaning unset).
    pub annealing_temp: f64,

    // 3' end constraints
    /// Maximum delta G of disruption at the 3' end (default: 9.0).
    pub max_end_stability: f64,
    /// Maximum number of G or C in the last 5 bases at the 3' end (default: 5).
    pub max_end_gc: usize,
    /// Required number of GCs at 3' end (default: 0).
    pub gc_clamp: usize,

    // Product size ranges: Vec<(min, max)>
    /// Acceptable product size ranges as `(min_bp, max_bp)` pairs.
    /// Empty means no constraint has been set (will use C library defaults).
    pub product_size_ranges: Vec<(usize, usize)>,
    /// Optimal product size in bp (default: 0, meaning unset).
    pub product_opt_size: usize,
    /// Maximum product Tm (default: very large).
    pub product_max_tm: f64,
    /// Minimum product Tm (default: very small).
    pub product_min_tm: f64,
    /// Optimal product Tm (default: 0.0, meaning unset).
    pub product_opt_tm: f64,

    // Pair complementarity
    /// Maximum pair complementarity score (default: 8.0).
    pub pair_max_compl_any: f64,
    /// Maximum pair 3' complementarity score (default: 3.0).
    pub pair_max_compl_end: f64,
    /// Maximum pair complementarity (thermodynamic) (default: 47.0).
    pub pair_max_compl_any_th: f64,
    /// Maximum pair 3' complementarity (thermodynamic) (default: 47.0).
    pub pair_max_compl_end_th: f64,
    /// Maximum Tm difference between left and right primers (default: 100.0).
    pub max_diff_tm: f64,

    // Pair template mispriming
    /// Maximum sum of template mispriming scores for a pair (default: 24.0).
    pub pair_max_template_mispriming: f64,
    /// Maximum pair template mispriming (thermodynamic) (default: 47.0).
    pub pair_max_template_mispriming_th: f64,
    /// Maximum pair repeat library complementarity (default: 24.0).
    pub pair_repeat_compl: f64,

    // Thermodynamic alignment modes
    /// Use thermodynamic alignment for oligo self-complementarity (default: true).
    pub thermodynamic_oligo_alignment: bool,
    /// Use thermodynamic alignment for template mispriming (default: true).
    pub thermodynamic_template_alignment: bool,

    // Lowercase masking
    /// Reject primers with lowercase base at 3' end (default: false).
    pub lowercase_masking: bool,

    /// Index of the first base in the sequence (default: 0).
    pub first_base_index: usize,
    /// Convert unknown bases to N (default: false).
    pub liberal_base: bool,

    // Minimum 3' distances between primers
    /// Minimum distance between 3' ends of left primers (default: -1, no limit).
    pub min_left_three_prime_distance: i32,
    /// Minimum distance between 3' ends of right primers (default: -1, no limit).
    pub min_right_three_prime_distance: i32,

    /// Parameters for sequencing primer design (only used with
    /// `PrimerTask::PickSequencingPrimers`).
    pub sequencing: SequencingParams,

    /// Constraints for primers (left and right).
    pub primer: OligoSettings,
    /// Constraints for internal oligos.
    pub internal_oligo: OligoSettings,

    /// Penalty weights for scoring primer pairs.
    pub pair_weights: PairWeights,
}

impl Default for PrimerSettings {
    fn default() -> Self {
        Self {
            task: PrimerTask::default(),
            pick_left_primer: true,
            pick_right_primer: true,
            pick_internal_oligo: false,
            num_return: 5,
            pick_anyway: false,
            tm_method: TmMethod::default(),
            salt_correction_method: SaltCorrectionMethod::default(),
            annealing_temp: -10.0,
            max_end_stability: 9.0,
            max_end_gc: 5,
            gc_clamp: 0,
            product_size_ranges: vec![(100, 300)],
            product_opt_size: 0,
            product_max_tm: 1_000_000.0,
            product_min_tm: -1_000_000.0,
            product_opt_tm: 0.0,
            pair_max_compl_any: 8.0,
            pair_max_compl_end: 3.0,
            pair_max_compl_any_th: 47.0,
            pair_max_compl_end_th: 47.0,
            max_diff_tm: 100.0,
            pair_max_template_mispriming: 24.0,
            pair_max_template_mispriming_th: 47.0,
            pair_repeat_compl: 24.0,
            thermodynamic_oligo_alignment: true,
            thermodynamic_template_alignment: true,
            lowercase_masking: false,
            first_base_index: 0,
            liberal_base: false,
            min_left_three_prime_distance: -1,
            min_right_three_prime_distance: -1,
            sequencing: SequencingParams::default(),
            primer: OligoSettings::default(),
            internal_oligo: OligoSettings::default(),
            pair_weights: PairWeights::default(),
        }
    }
}

/// Builder for [`PrimerSettings`].
///
/// Provides a fluent API for constructing settings, mapping closely to
/// primer3's `PRIMER_*` `BoulderIO` tags.
///
/// # Example
///
/// ```no_run
/// use primer3::PrimerSettings;
///
/// let settings = PrimerSettings::builder()
///     .primer_opt_tm(60.0)
///     .primer_min_tm(57.0)
///     .primer_max_tm(63.0)
///     .primer_opt_size(20)
///     .product_size_range(75, 150)
///     .product_size_range(150, 250)
///     .num_return(10)
///     .build()
///     .unwrap();
/// ```
#[derive(Debug, Clone)]
pub struct PrimerSettingsBuilder {
    settings: PrimerSettings,
    product_size_ranges_set: bool,
}

impl PrimerSettings {
    /// Creates a new builder with default settings.
    pub fn builder() -> PrimerSettingsBuilder {
        PrimerSettingsBuilder {
            settings: PrimerSettings::default(),
            product_size_ranges_set: false,
        }
    }
}

impl PrimerSettingsBuilder {
    /// Sets the design task.
    pub fn task(mut self, task: PrimerTask) -> Self {
        self.settings.task = task;
        self
    }

    /// Sets whether to pick a left primer.
    pub fn pick_left_primer(mut self, pick: bool) -> Self {
        self.settings.pick_left_primer = pick;
        self
    }

    /// Sets whether to pick a right primer.
    pub fn pick_right_primer(mut self, pick: bool) -> Self {
        self.settings.pick_right_primer = pick;
        self
    }

    /// Sets whether to pick an internal oligo.
    pub fn pick_internal_oligo(mut self, pick: bool) -> Self {
        self.settings.pick_internal_oligo = pick;
        self
    }

    /// Sets the number of primer pairs to return.
    pub fn num_return(mut self, n: usize) -> Self {
        self.settings.num_return = n;
        self
    }

    // Primer Tm constraints
    /// Sets the optimal primer Tm.
    pub fn primer_opt_tm(mut self, tm: f64) -> Self {
        self.settings.primer.opt_tm = tm;
        self
    }

    /// Sets the minimum primer Tm.
    pub fn primer_min_tm(mut self, tm: f64) -> Self {
        self.settings.primer.min_tm = tm;
        self
    }

    /// Sets the maximum primer Tm.
    pub fn primer_max_tm(mut self, tm: f64) -> Self {
        self.settings.primer.max_tm = tm;
        self
    }

    // Primer size constraints
    /// Sets the optimal primer size.
    pub fn primer_opt_size(mut self, size: usize) -> Self {
        self.settings.primer.opt_size = size;
        self
    }

    /// Sets the minimum primer size.
    pub fn primer_min_size(mut self, size: usize) -> Self {
        self.settings.primer.min_size = size;
        self
    }

    /// Sets the maximum primer size.
    pub fn primer_max_size(mut self, size: usize) -> Self {
        self.settings.primer.max_size = size;
        self
    }

    // GC constraints
    /// Sets the minimum primer GC content (percentage).
    pub fn primer_min_gc(mut self, gc: f64) -> Self {
        self.settings.primer.min_gc = gc;
        self
    }

    /// Sets the maximum primer GC content (percentage).
    pub fn primer_max_gc(mut self, gc: f64) -> Self {
        self.settings.primer.max_gc = gc;
        self
    }

    // Salt concentrations
    /// Sets the monovalent cation concentration (mM) for primer Tm calculation.
    pub fn primer_mv_conc(mut self, conc: f64) -> Self {
        self.settings.primer.conditions.mv_conc = conc;
        self
    }

    /// Sets the divalent cation concentration (mM) for primer Tm calculation.
    pub fn primer_dv_conc(mut self, conc: f64) -> Self {
        self.settings.primer.conditions.dv_conc = conc;
        self
    }

    /// Sets the dNTP concentration (mM) for primer Tm calculation.
    pub fn primer_dntp_conc(mut self, conc: f64) -> Self {
        self.settings.primer.conditions.dntp_conc = conc;
        self
    }

    /// Sets the DNA strand concentration (nM) for primer Tm calculation.
    pub fn primer_dna_conc(mut self, conc: f64) -> Self {
        self.settings.primer.conditions.dna_conc = conc;
        self
    }

    // Product size ranges
    /// Adds a product size range `(min_bp, max_bp)`.
    ///
    /// The first call clears the default range; subsequent calls append.
    pub fn product_size_range(mut self, min_bp: usize, max_bp: usize) -> Self {
        if !self.product_size_ranges_set {
            self.settings.product_size_ranges.clear();
            self.product_size_ranges_set = true;
        }
        self.settings.product_size_ranges.push((min_bp, max_bp));
        self
    }

    /// Replaces all product size ranges.
    pub fn product_size_ranges(mut self, ranges: Vec<(usize, usize)>) -> Self {
        self.settings.product_size_ranges = ranges;
        self.product_size_ranges_set = true;
        self
    }

    /// Sets the maximum allowed Tm difference between left and right primers.
    pub fn max_diff_tm(mut self, diff: f64) -> Self {
        self.settings.max_diff_tm = diff;
        self
    }

    /// Sets the Tm calculation method.
    pub fn tm_method(mut self, method: TmMethod) -> Self {
        self.settings.tm_method = method;
        self
    }

    /// Sets the salt correction method.
    pub fn salt_correction_method(mut self, method: SaltCorrectionMethod) -> Self {
        self.settings.salt_correction_method = method;
        self
    }

    /// Consumes the builder, validates settings, and returns the settings.
    ///
    /// # Errors
    ///
    /// Returns an error if:
    /// - `min_tm > opt_tm` or `opt_tm > max_tm` for primer settings
    /// - `min_size > opt_size` or `opt_size > max_size` for primer settings
    /// - `product_size_ranges` is empty
    pub fn build(self) -> Result<PrimerSettings> {
        let p = &self.settings.primer;
        if p.min_tm > p.opt_tm || p.opt_tm > p.max_tm {
            return Err(Primer3Error::InvalidSetting(format!(
                "primer Tm constraints violated: min_tm ({}) <= opt_tm ({}) <= max_tm ({}) required",
                p.min_tm, p.opt_tm, p.max_tm,
            )));
        }
        if p.min_size > p.opt_size || p.opt_size > p.max_size {
            return Err(Primer3Error::InvalidSetting(format!(
                "primer size constraints violated: min_size ({}) <= opt_size ({}) <= max_size ({}) required",
                p.min_size, p.opt_size, p.max_size,
            )));
        }
        if self.settings.product_size_ranges.is_empty() {
            return Err(Primer3Error::InvalidSetting(
                "product_size_ranges must not be empty".into(),
            ));
        }
        Ok(self.settings)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_default_settings() {
        let s = PrimerSettings::default();
        assert_eq!(s.task, PrimerTask::PickPcrPrimers);
        assert!(s.pick_left_primer);
        assert!(s.pick_right_primer);
        assert!(!s.pick_internal_oligo);
        assert_eq!(s.num_return, 5);
        assert_eq!(s.product_size_ranges, vec![(100, 300)]);
    }

    #[test]
    fn test_default_oligo_settings() {
        let o = OligoSettings::default();
        assert_eq!(o.opt_size, 20);
        assert_eq!(o.min_size, 18);
        assert_eq!(o.max_size, 27);
        assert!((o.opt_tm - 60.0).abs() < f64::EPSILON);
        assert!((o.min_tm - 57.0).abs() < f64::EPSILON);
        assert!((o.max_tm - 63.0).abs() < f64::EPSILON);
        assert!((o.min_gc - 20.0).abs() < f64::EPSILON);
        assert!((o.max_gc - 80.0).abs() < f64::EPSILON);
    }

    #[test]
    fn test_default_sequencing_params() {
        let s = SequencingParams::default();
        assert_eq!(s.lead, 50);
        assert_eq!(s.spacing, 500);
        assert_eq!(s.interval, 250);
        assert_eq!(s.accuracy, 20);
    }

    #[test]
    fn test_builder_minimal() {
        let s = PrimerSettings::builder().build().unwrap();
        assert_eq!(s.task, PrimerTask::PickPcrPrimers);
        assert_eq!(s.product_size_ranges, vec![(100, 300)]);
    }

    #[test]
    fn test_builder_product_size_range_clears_default() {
        let s = PrimerSettings::builder().product_size_range(50, 150).build().unwrap();
        assert_eq!(s.product_size_ranges, vec![(50, 150)]);
    }

    #[test]
    fn test_builder_multiple_product_size_ranges() {
        let s = PrimerSettings::builder()
            .product_size_range(50, 150)
            .product_size_range(150, 300)
            .build()
            .unwrap();
        assert_eq!(s.product_size_ranges, vec![(50, 150), (150, 300)]);
    }

    #[test]
    fn test_builder_tm_validation() {
        // min_tm > opt_tm should fail
        let result = PrimerSettings::builder().primer_min_tm(65.0).primer_opt_tm(60.0).build();
        assert!(result.is_err());
    }

    #[test]
    fn test_builder_size_validation() {
        // min_size > opt_size should fail
        let result = PrimerSettings::builder().primer_min_size(25).primer_opt_size(20).build();
        assert!(result.is_err());
    }

    #[test]
    fn test_builder_empty_ranges_fails() {
        let result = PrimerSettings::builder().product_size_ranges(vec![]).build();
        assert!(result.is_err());
    }

    #[test]
    fn test_builder_sets_task() {
        let s = PrimerSettings::builder().task(PrimerTask::CheckPrimers).build().unwrap();
        assert_eq!(s.task, PrimerTask::CheckPrimers);
    }

    #[test]
    fn test_builder_sets_concentrations() {
        let s =
            PrimerSettings::builder().primer_mv_conc(100.0).primer_dna_conc(250.0).build().unwrap();
        assert!((s.primer.conditions.mv_conc - 100.0).abs() < f64::EPSILON);
        assert!((s.primer.conditions.dna_conc - 250.0).abs() < f64::EPSILON);
    }
}