1use serde::{Deserialize, Serialize};
11
12use crate::acquisition::AcquisitionModeHint;
13use crate::playbooks::error::ValidationError;
14use crate::types::{ExecutionMode, SessionMode, TargetClass, TelemetryLevel};
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct AcquisitionDefaults {
38 pub mode: AcquisitionModeHint,
40 pub execution_mode: ExecutionMode,
42 pub session_mode: SessionMode,
44 pub telemetry_level: TelemetryLevel,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub sticky_session_ttl_secs: Option<u64>,
50 #[serde(default)]
52 pub enable_warmup: bool,
53 #[serde(default = "default_retry_budget")]
56 pub retry_budget: u32,
57 #[serde(default = "default_backoff_ms")]
59 pub backoff_base_ms: u64,
60}
61
62const fn default_retry_budget() -> u32 {
63 2
64}
65
66const fn default_backoff_ms() -> u64 {
67 250
68}
69
70impl AcquisitionDefaults {
71 #[must_use]
78 pub const fn default_for(target_class: TargetClass) -> Self {
79 match target_class {
80 TargetClass::Api => Self {
81 mode: AcquisitionModeHint::Fast,
82 execution_mode: ExecutionMode::Http,
83 session_mode: SessionMode::Stateless,
84 telemetry_level: TelemetryLevel::Standard,
85 sticky_session_ttl_secs: None,
86 enable_warmup: false,
87 retry_budget: 2,
88 backoff_base_ms: 250,
89 },
90 TargetClass::ContentSite | TargetClass::Unknown => Self {
91 mode: AcquisitionModeHint::Resilient,
92 execution_mode: ExecutionMode::Http,
93 session_mode: SessionMode::Stateless,
94 telemetry_level: TelemetryLevel::Standard,
95 sticky_session_ttl_secs: None,
96 enable_warmup: false,
97 retry_budget: 2,
98 backoff_base_ms: 250,
99 },
100 TargetClass::HighSecurity => Self {
101 mode: AcquisitionModeHint::Hostile,
102 execution_mode: ExecutionMode::Browser,
103 session_mode: SessionMode::Sticky,
104 telemetry_level: TelemetryLevel::Deep,
105 sticky_session_ttl_secs: Some(900),
106 enable_warmup: true,
107 retry_budget: 4,
108 backoff_base_ms: 500,
109 },
110 }
111 }
112}
113
114impl Default for AcquisitionDefaults {
115 fn default() -> Self {
116 Self::default_for(TargetClass::Unknown)
117 }
118}
119
120#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
137pub struct AcquisitionOverrides {
138 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub mode: Option<AcquisitionModeHint>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub execution_mode: Option<ExecutionMode>,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub session_mode: Option<SessionMode>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub telemetry_level: Option<TelemetryLevel>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub retry_budget: Option<u32>,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub backoff_base_ms: Option<u64>,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub enable_warmup: Option<bool>,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174pub struct ProxyPreference {
175 pub preferred_protocol: String,
177 #[serde(default)]
180 pub require_sticky: bool,
181 #[serde(default)]
183 pub require_residential: bool,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub max_latency_ms: Option<u64>,
187}
188
189const SUPPORTED_PROXY_PROTOCOLS: &[&str] = &["http", "https", "socks5"];
190
191impl ProxyPreference {
192 #[must_use]
194 pub fn default_for(target_class: TargetClass) -> Self {
195 match target_class {
196 TargetClass::Api | TargetClass::Unknown => Self {
197 preferred_protocol: "https".to_string(),
198 require_sticky: false,
199 require_residential: false,
200 max_latency_ms: Some(2_000),
201 },
202 TargetClass::ContentSite => Self {
203 preferred_protocol: "https".to_string(),
204 require_sticky: false,
205 require_residential: false,
206 max_latency_ms: Some(1_500),
207 },
208 TargetClass::HighSecurity => Self {
209 preferred_protocol: "https".to_string(),
210 require_sticky: true,
211 require_residential: true,
212 max_latency_ms: Some(800),
213 },
214 }
215 }
216}
217
218impl Default for ProxyPreference {
219 fn default() -> Self {
220 Self::default_for(TargetClass::Unknown)
221 }
222}
223
224#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
237pub struct PacingProfile {
238 pub rate_limit_rps: f64,
240 pub jitter_pct: f64,
242 pub min_request_interval_ms: u64,
244}
245
246impl PacingProfile {
247 #[must_use]
249 pub const fn default_for(target_class: TargetClass) -> Self {
250 match target_class {
251 TargetClass::Api => Self {
252 rate_limit_rps: 5.0,
253 jitter_pct: 0.05,
254 min_request_interval_ms: 200,
255 },
256 TargetClass::ContentSite => Self {
257 rate_limit_rps: 3.0,
258 jitter_pct: 0.10,
259 min_request_interval_ms: 300,
260 },
261 TargetClass::HighSecurity => Self {
262 rate_limit_rps: 0.5,
263 jitter_pct: 0.25,
264 min_request_interval_ms: 2_000,
265 },
266 TargetClass::Unknown => Self {
267 rate_limit_rps: 2.0,
268 jitter_pct: 0.10,
269 min_request_interval_ms: 500,
270 },
271 }
272 }
273}
274
275impl Default for PacingProfile {
276 fn default() -> Self {
277 Self::default_for(TargetClass::Unknown)
278 }
279}
280
281#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
304#[serde(tag = "kind", rename_all = "snake_case")]
305pub enum EscalationStrategy {
306 Capped {
309 ceiling: AcquisitionModeHint,
311 },
312 Linear {
314 steps: Vec<AcquisitionModeHint>,
316 },
317}
318
319impl EscalationStrategy {
320 #[must_use]
322 pub fn ceiling(&self) -> AcquisitionModeHint {
323 match self {
324 Self::Capped { ceiling } => *ceiling,
325 Self::Linear { steps } => steps
326 .last()
327 .copied()
328 .unwrap_or(AcquisitionModeHint::Resilient),
329 }
330 }
331
332 #[must_use]
334 pub fn first(&self) -> AcquisitionModeHint {
335 match self {
336 Self::Capped { ceiling } => *ceiling,
337 Self::Linear { steps } => steps
338 .first()
339 .copied()
340 .unwrap_or(AcquisitionModeHint::Resilient),
341 }
342 }
343
344 #[must_use]
347 pub fn stages(&self) -> Vec<AcquisitionModeHint> {
348 match self {
349 Self::Capped { ceiling } => vec![*ceiling],
350 Self::Linear { steps } => {
351 let mut seen: Vec<AcquisitionModeHint> = Vec::new();
352 for stage in steps {
353 if !seen.contains(stage) {
354 seen.push(*stage);
355 }
356 }
357 seen
358 }
359 }
360 }
361}
362
363impl Default for EscalationStrategy {
364 fn default() -> Self {
365 Self::Capped {
366 ceiling: AcquisitionModeHint::Resilient,
367 }
368 }
369}
370
371#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
378#[serde(rename_all = "snake_case")]
379pub enum ResolutionSource {
380 RequestOverride,
382 PlaybookDefault,
384 GlobalDefault,
386}
387
388#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
411pub struct Playbook {
412 pub id: String,
416 pub target_class: TargetClass,
418 #[serde(default)]
420 pub description: String,
421 pub acquisition: AcquisitionDefaults,
423 #[serde(default)]
425 pub proxy_preference: ProxyPreference,
426 #[serde(default)]
428 pub pacing: PacingProfile,
429 #[serde(default)]
431 pub escalation: EscalationStrategy,
432}
433
434impl Playbook {
435 pub fn validate(&self) -> Result<(), ValidationError> {
477 if self.id.trim().is_empty() {
478 return Err(ValidationError::invalid_field(
479 self.id.clone(),
480 "id",
481 self.id.clone(),
482 "playbook id must be a non-empty string",
483 ));
484 }
485 validate_acquisition(self)?;
486 validate_proxy_preference(self)?;
487 validate_pacing(self)?;
488 validate_escalation(self)?;
489 Ok(())
490 }
491
492 #[must_use]
515 pub const fn to_runtime_policy_hints(&self) -> crate::acquisition::RuntimePolicyHints {
516 crate::acquisition::RuntimePolicyHints {
517 execution_mode: Some(self.acquisition.execution_mode),
518 session_mode: Some(self.acquisition.session_mode),
519 telemetry_level: Some(self.acquisition.telemetry_level),
520 risk_score: None,
521 max_retries: Some(self.acquisition.retry_budget),
522 backoff_base_ms: Some(self.acquisition.backoff_base_ms),
523 enable_warmup: Some(self.acquisition.enable_warmup),
524 }
525 }
526}
527
528fn validate_acquisition(pb: &Playbook) -> Result<(), ValidationError> {
529 let acq = &pb.acquisition;
530 if acq.retry_budget == 0 {
531 return Err(ValidationError::invalid_field(
532 pb.id.clone(),
533 "acquisition.retry_budget",
534 acq.retry_budget,
535 "retry_budget must be > 0",
536 ));
537 }
538 if acq.retry_budget > 32 {
539 return Err(ValidationError::invalid_field(
540 pb.id.clone(),
541 "acquisition.retry_budget",
542 acq.retry_budget,
543 "retry_budget must be <= 32",
544 ));
545 }
546 if acq.backoff_base_ms == 0 {
547 return Err(ValidationError::invalid_field(
548 pb.id.clone(),
549 "acquisition.backoff_base_ms",
550 acq.backoff_base_ms,
551 "backoff_base_ms must be > 0",
552 ));
553 }
554 if acq.backoff_base_ms > 60_000 {
555 return Err(ValidationError::invalid_field(
556 pb.id.clone(),
557 "acquisition.backoff_base_ms",
558 acq.backoff_base_ms,
559 "backoff_base_ms must be <= 60_000",
560 ));
561 }
562 if let Some(ttl) = acq.sticky_session_ttl_secs
563 && ttl == 0
564 {
565 return Err(ValidationError::invalid_field(
566 pb.id.clone(),
567 "acquisition.sticky_session_ttl_secs",
568 ttl,
569 "sticky_session_ttl_secs must be > 0 when set",
570 ));
571 }
572 Ok(())
573}
574
575fn validate_proxy_preference(pb: &Playbook) -> Result<(), ValidationError> {
576 let proxy = &pb.proxy_preference;
577 let proto = proxy.preferred_protocol.as_str();
578 if !SUPPORTED_PROXY_PROTOCOLS.contains(&proto) {
579 return Err(ValidationError::invalid_field(
580 pb.id.clone(),
581 "proxy_preference.preferred_protocol",
582 proto,
583 format!("preferred_protocol must be one of {SUPPORTED_PROXY_PROTOCOLS:?}"),
584 ));
585 }
586 if let Some(max_latency) = proxy.max_latency_ms
587 && max_latency == 0
588 {
589 return Err(ValidationError::invalid_field(
590 pb.id.clone(),
591 "proxy_preference.max_latency_ms",
592 max_latency,
593 "max_latency_ms must be > 0 when set",
594 ));
595 }
596 Ok(())
597}
598
599fn validate_pacing(pb: &Playbook) -> Result<(), ValidationError> {
600 let pacing = &pb.pacing;
601 if pacing.rate_limit_rps <= 0.0 {
602 return Err(ValidationError::invalid_field(
603 pb.id.clone(),
604 "pacing.rate_limit_rps",
605 pacing.rate_limit_rps,
606 "rate_limit_rps must be > 0",
607 ));
608 }
609 if pacing.rate_limit_rps > 1000.0 {
610 return Err(ValidationError::invalid_field(
611 pb.id.clone(),
612 "pacing.rate_limit_rps",
613 pacing.rate_limit_rps,
614 "rate_limit_rps must be <= 1000",
615 ));
616 }
617 if !(0.0..=1.0).contains(&pacing.jitter_pct) {
618 return Err(ValidationError::invalid_field(
619 pb.id.clone(),
620 "pacing.jitter_pct",
621 pacing.jitter_pct,
622 "jitter_pct must be in [0.0, 1.0]",
623 ));
624 }
625 if pacing.min_request_interval_ms == 0 {
626 return Err(ValidationError::invalid_field(
627 pb.id.clone(),
628 "pacing.min_request_interval_ms",
629 pacing.min_request_interval_ms,
630 "min_request_interval_ms must be > 0",
631 ));
632 }
633 Ok(())
634}
635
636fn validate_escalation(pb: &Playbook) -> Result<(), ValidationError> {
637 match &pb.escalation {
638 EscalationStrategy::Capped { .. } => Ok(()),
639 EscalationStrategy::Linear { steps } => {
640 if steps.is_empty() {
641 return Err(ValidationError::invalid_field(
642 pb.id.clone(),
643 "escalation.steps",
644 "<empty>",
645 "linear escalation must contain at least one stage",
646 ));
647 }
648 Ok(())
649 }
650 }
651}
652
653#[cfg(test)]
654#[allow(
655 clippy::unwrap_used,
656 clippy::expect_used,
657 clippy::panic,
658 clippy::indexing_slicing
659)]
660mod tests {
661 use super::*;
662
663 fn ok_playbook() -> Playbook {
664 Playbook {
665 id: "tier1-static".to_string(),
666 target_class: TargetClass::ContentSite,
667 description: "static content".to_string(),
668 acquisition: AcquisitionDefaults::default_for(TargetClass::ContentSite),
669 proxy_preference: ProxyPreference::default_for(TargetClass::ContentSite),
670 pacing: PacingProfile::default_for(TargetClass::ContentSite),
671 escalation: EscalationStrategy::Capped {
672 ceiling: AcquisitionModeHint::Resilient,
673 },
674 }
675 }
676
677 #[test]
678 fn defaults_match_target_class_taxonomy() {
679 let api = AcquisitionDefaults::default_for(TargetClass::Api);
680 assert_eq!(api.mode, AcquisitionModeHint::Fast);
681
682 let high = AcquisitionDefaults::default_for(TargetClass::HighSecurity);
683 assert_eq!(high.mode, AcquisitionModeHint::Hostile);
684 assert_eq!(high.session_mode, SessionMode::Sticky);
685 assert!(high.enable_warmup);
686 }
687
688 #[test]
689 fn valid_playbook_passes_validation() {
690 assert!(ok_playbook().validate().is_ok());
691 }
692
693 #[test]
694 fn empty_id_is_rejected_with_field_path() {
695 let mut pb = ok_playbook();
696 pb.id.clear();
697 let err = pb.validate().expect_err("empty id");
698 assert_eq!(err.field_path(), Some("id"));
699 assert_eq!(err.bad_value(), Some(""));
700 assert!(err.to_string().contains("id"));
701 }
702
703 #[test]
704 fn zero_retry_budget_is_rejected() {
705 let mut pb = ok_playbook();
706 pb.acquisition.retry_budget = 0;
707 let err = pb.validate().expect_err("zero retry budget");
708 assert_eq!(err.field_path(), Some("acquisition.retry_budget"));
709 assert!(err.bad_value().is_some());
710 }
711
712 #[test]
713 fn negative_pacing_rate_is_rejected() {
714 let mut pb = ok_playbook();
715 pb.pacing.rate_limit_rps = -0.5;
716 let err = pb.validate().expect_err("negative pacing");
717 assert_eq!(err.field_path(), Some("pacing.rate_limit_rps"));
718 assert_eq!(err.bad_value(), Some("-0.5"));
719 }
720
721 #[test]
722 fn jitter_out_of_range_is_rejected() {
723 let mut pb = ok_playbook();
724 pb.pacing.jitter_pct = 1.5;
725 let err = pb.validate().expect_err("jitter out of range");
726 assert_eq!(err.field_path(), Some("pacing.jitter_pct"));
727 }
728
729 #[test]
730 fn unknown_proxy_protocol_is_rejected() {
731 let mut pb = ok_playbook();
732 pb.proxy_preference.preferred_protocol = "ftp".to_string();
733 let err = pb.validate().expect_err("unknown protocol");
734 assert_eq!(
735 err.field_path(),
736 Some("proxy_preference.preferred_protocol")
737 );
738 assert_eq!(err.bad_value(), Some("ftp"));
739 }
740
741 #[test]
742 fn empty_linear_escalation_is_rejected() {
743 let mut pb = ok_playbook();
744 pb.escalation = EscalationStrategy::Linear { steps: Vec::new() };
745 let err = pb.validate().expect_err("empty linear");
746 assert_eq!(err.field_path(), Some("escalation.steps"));
747 }
748
749 #[test]
750 fn to_runtime_policy_hints_carries_acquisition_fields() {
751 let pb = ok_playbook();
752 let hints = pb.to_runtime_policy_hints();
753 assert_eq!(hints.execution_mode, Some(pb.acquisition.execution_mode));
754 assert_eq!(hints.session_mode, Some(pb.acquisition.session_mode));
755 assert_eq!(hints.telemetry_level, Some(pb.acquisition.telemetry_level));
756 assert_eq!(hints.max_retries, Some(pb.acquisition.retry_budget));
757 assert_eq!(hints.backoff_base_ms, Some(pb.acquisition.backoff_base_ms));
758 assert_eq!(hints.enable_warmup, Some(pb.acquisition.enable_warmup));
759 }
760
761 #[test]
762 fn escalation_stages_dedup() {
763 let dup = EscalationStrategy::Linear {
764 steps: vec![
765 AcquisitionModeHint::Fast,
766 AcquisitionModeHint::Fast,
767 AcquisitionModeHint::Resilient,
768 ],
769 };
770 assert_eq!(
771 dup.stages(),
772 vec![AcquisitionModeHint::Fast, AcquisitionModeHint::Resilient]
773 );
774 }
775}