1use std::collections::HashMap;
46
47use serde::{Deserialize, Serialize};
48
49use crate::acquisition::AcquisitionModeHint;
50use crate::playbooks::error::ValidationError;
51use crate::playbooks::schema::{
52 AcquisitionDefaults, AcquisitionOverrides, EscalationStrategy, PacingProfile, Playbook,
53 ProxyPreference, ResolutionSource,
54};
55use crate::types::{ExecutionMode, SessionMode, TargetClass, TelemetryLevel};
56
57#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
80pub struct PlaybookOverrides {
81 #[serde(default)]
83 pub acquisition: AcquisitionOverrides,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub proxy_preference: Option<ProxyPreference>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub pacing: Option<PacingProfile>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub escalation: Option<EscalationStrategy>,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub struct ResolvedAcquisition {
99 pub mode: AcquisitionModeHint,
101 pub mode_source: ResolutionSource,
103 pub execution_mode: ExecutionMode,
105 pub execution_mode_source: ResolutionSource,
107 pub session_mode: SessionMode,
109 pub session_mode_source: ResolutionSource,
111 pub telemetry_level: TelemetryLevel,
113 pub telemetry_level_source: ResolutionSource,
115 pub sticky_session_ttl_secs: Option<u64>,
117 pub sticky_session_ttl_source: ResolutionSource,
119 pub enable_warmup: bool,
121 pub enable_warmup_source: ResolutionSource,
123 pub retry_budget: u32,
125 pub retry_budget_source: ResolutionSource,
127 pub backoff_base_ms: u64,
129 pub backoff_base_source: ResolutionSource,
131}
132
133#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
135pub struct ResolvedPlaybook {
136 pub playbook_id: String,
139 pub target_class: TargetClass,
141 pub acquisition: ResolvedAcquisition,
143 pub proxy_preference: ProxyPreference,
145 pub proxy_preference_source: ResolutionSource,
147 pub pacing: PacingProfile,
149 pub pacing_source: ResolutionSource,
151 pub escalation: EscalationStrategy,
153 pub escalation_source: ResolutionSource,
155}
156
157impl ResolvedPlaybook {
158 #[must_use]
175 pub const fn to_runtime_policy_hints(&self) -> crate::acquisition::RuntimePolicyHints {
176 crate::acquisition::RuntimePolicyHints {
177 execution_mode: Some(self.acquisition.execution_mode),
178 session_mode: Some(self.acquisition.session_mode),
179 telemetry_level: Some(self.acquisition.telemetry_level),
180 risk_score: None,
181 max_retries: Some(self.acquisition.retry_budget),
182 backoff_base_ms: Some(self.acquisition.backoff_base_ms),
183 enable_warmup: Some(self.acquisition.enable_warmup),
184 }
185 }
186
187 #[must_use]
204 pub fn to_acquisition_policy(&self) -> crate::acquisition::AcquisitionPolicy {
205 crate::acquisition::map_policy_hints(&self.to_runtime_policy_hints())
206 }
207}
208
209#[derive(Debug, Clone)]
228pub struct PlaybookResolver {
229 playbooks: HashMap<String, Playbook>,
230 by_target_class: HashMap<TargetClass, String>,
231 global_default: Playbook,
232}
233
234impl PlaybookResolver {
235 #[must_use]
263 pub fn with_builtin_defaults() -> Self {
264 let playbooks = crate::playbooks::builtin::builtin_playbooks();
270 #[allow(clippy::expect_used)]
271 Self::from_playbooks(playbooks).expect("builtin playbooks are validated at compile time")
272 }
273
274 pub fn from_playbooks<I>(playbooks: I) -> Result<Self, ValidationError>
285 where
286 I: IntoIterator<Item = Playbook>,
287 {
288 let mut by_id: HashMap<String, Playbook> = HashMap::new();
289 let mut by_target_class: HashMap<TargetClass, String> = HashMap::new();
290 let mut global_default: Option<Playbook> = None;
291
292 for pb in playbooks {
293 pb.validate()?;
294 if by_id.contains_key(&pb.id) {
295 return Err(ValidationError::DuplicateId { playbook_id: pb.id });
296 }
297 if pb.id == "unknown" {
298 global_default = Some(pb.clone());
299 }
300 by_target_class
301 .entry(pb.target_class)
302 .or_insert_with(|| pb.id.clone());
303 by_id.insert(pb.id.clone(), pb);
304 }
305
306 let global_default = global_default.unwrap_or_else(|| Playbook {
307 id: "unknown".to_string(),
308 target_class: TargetClass::Unknown,
309 description: "Fallback when no playbook matches".to_string(),
310 acquisition: AcquisitionDefaults::default_for(TargetClass::Unknown),
311 proxy_preference: ProxyPreference::default_for(TargetClass::Unknown),
312 pacing: PacingProfile::default_for(TargetClass::Unknown),
313 escalation: EscalationStrategy::default(),
314 });
315
316 Ok(Self {
317 playbooks: by_id,
318 by_target_class,
319 global_default,
320 })
321 }
322
323 #[must_use]
325 pub fn contains(&self, id: &str) -> bool {
326 self.playbooks.contains_key(id)
327 }
328
329 #[must_use]
331 pub fn playbook_ids(&self) -> Vec<String> {
332 self.playbooks.keys().cloned().collect()
333 }
334
335 pub fn resolve(
366 &self,
367 target_class: TargetClass,
368 playbook_id: &str,
369 overrides: &PlaybookOverrides,
370 ) -> Result<ResolvedPlaybook, ValidationError> {
371 let playbook = self.lookup_playbook(target_class, playbook_id)?;
372 Ok(self.merge(playbook, target_class, overrides))
373 }
374
375 pub fn resolve_optional(
401 &self,
402 target_class: TargetClass,
403 playbook_id: Option<&str>,
404 overrides: &PlaybookOverrides,
405 ) -> Result<ResolvedPlaybook, ValidationError> {
406 let playbook = match playbook_id {
407 Some(id) => self.lookup_by_id(id)?,
408 None => self.lookup_by_target_class(target_class),
409 };
410 Ok(self.merge(playbook, target_class, overrides))
411 }
412
413 fn lookup_playbook(
414 &self,
415 target_class: TargetClass,
416 playbook_id: &str,
417 ) -> Result<&Playbook, ValidationError> {
418 if !playbook_id.is_empty() {
419 return self.lookup_by_id(playbook_id);
420 }
421 Ok(self.lookup_by_target_class(target_class))
422 }
423
424 fn lookup_by_id(&self, id: &str) -> Result<&Playbook, ValidationError> {
425 self.playbooks
426 .get(id)
427 .ok_or_else(|| ValidationError::UnknownPlaybook {
428 playbook_id: id.to_string(),
429 })
430 }
431
432 fn lookup_by_target_class(&self, target_class: TargetClass) -> &Playbook {
433 if let Some(id) = self.by_target_class.get(&target_class)
434 && let Some(pb) = self.playbooks.get(id)
435 {
436 return pb;
437 }
438 if let Some(id) = self.by_target_class.get(&TargetClass::Unknown)
439 && let Some(pb) = self.playbooks.get(id)
440 {
441 return pb;
442 }
443 &self.global_default
444 }
445
446 fn merge(
447 &self,
448 playbook: &Playbook,
449 target_class: TargetClass,
450 overrides: &PlaybookOverrides,
451 ) -> ResolvedPlaybook {
452 let default_acq = AcquisitionDefaults::default_for(target_class);
453 let playbook_acq = &playbook.acquisition;
454 let is_global_default = playbook.id == self.global_default.id;
455
456 let pick_mode = overrides.acquisition.mode.unwrap_or(playbook_acq.mode);
457 let pick_execution = overrides
458 .acquisition
459 .execution_mode
460 .unwrap_or(playbook_acq.execution_mode);
461 let pick_session = overrides
462 .acquisition
463 .session_mode
464 .unwrap_or(playbook_acq.session_mode);
465 let pick_telemetry = overrides
466 .acquisition
467 .telemetry_level
468 .unwrap_or(playbook_acq.telemetry_level);
469 let pick_sticky = playbook_acq.sticky_session_ttl_secs;
470 let pick_warmup = overrides
471 .acquisition
472 .enable_warmup
473 .unwrap_or(playbook_acq.enable_warmup);
474 let pick_retry = overrides
475 .acquisition
476 .retry_budget
477 .unwrap_or(playbook_acq.retry_budget);
478 let pick_backoff = overrides
479 .acquisition
480 .backoff_base_ms
481 .unwrap_or(playbook_acq.backoff_base_ms);
482
483 let acquisition = ResolvedAcquisition {
484 mode: pick_mode,
485 mode_source: source_for_mode(
486 overrides,
487 playbook_acq,
488 default_acq.mode,
489 is_global_default,
490 ),
491 execution_mode: pick_execution,
492 execution_mode_source: source_for_scalar(
493 overrides.acquisition.execution_mode.is_some(),
494 is_global_default,
495 ),
496 session_mode: pick_session,
497 session_mode_source: source_for_scalar(
498 overrides.acquisition.session_mode.is_some(),
499 is_global_default,
500 ),
501 telemetry_level: pick_telemetry,
502 telemetry_level_source: source_for_scalar(
503 overrides.acquisition.telemetry_level.is_some(),
504 is_global_default,
505 ),
506 sticky_session_ttl_secs: pick_sticky,
507 sticky_session_ttl_source: source_for_scalar(
508 false,
509 is_global_default || playbook_acq.sticky_session_ttl_secs.is_none(),
510 ),
511 enable_warmup: pick_warmup,
512 enable_warmup_source: source_for_scalar(
513 overrides.acquisition.enable_warmup.is_some(),
514 is_global_default,
515 ),
516 retry_budget: pick_retry,
517 retry_budget_source: source_for_scalar(
518 overrides.acquisition.retry_budget.is_some(),
519 is_global_default,
520 ),
521 backoff_base_ms: pick_backoff,
522 backoff_base_source: source_for_scalar(
523 overrides.acquisition.backoff_base_ms.is_some(),
524 is_global_default,
525 ),
526 };
527
528 ResolvedPlaybook {
529 playbook_id: playbook.id.clone(),
530 target_class,
531 acquisition,
532 proxy_preference: overrides
533 .proxy_preference
534 .clone()
535 .unwrap_or_else(|| playbook.proxy_preference.clone()),
536 proxy_preference_source: if overrides.proxy_preference.is_some() {
537 ResolutionSource::RequestOverride
538 } else if is_global_default {
539 ResolutionSource::GlobalDefault
540 } else {
541 ResolutionSource::PlaybookDefault
542 },
543 pacing: overrides
544 .pacing
545 .clone()
546 .unwrap_or_else(|| playbook.pacing.clone()),
547 pacing_source: tier_source(overrides.pacing.is_some(), is_global_default),
548 escalation: overrides
549 .escalation
550 .clone()
551 .unwrap_or_else(|| playbook.escalation.clone()),
552 escalation_source: tier_source(overrides.escalation.is_some(), is_global_default),
553 }
554 }
555}
556
557const fn source_for_mode(
558 overrides: &PlaybookOverrides,
559 _playbook: &AcquisitionDefaults,
560 _default: AcquisitionModeHint,
561 is_global_default: bool,
562) -> ResolutionSource {
563 if overrides.acquisition.mode.is_some() {
564 ResolutionSource::RequestOverride
565 } else if is_global_default {
566 ResolutionSource::GlobalDefault
567 } else {
568 ResolutionSource::PlaybookDefault
569 }
570}
571
572const fn source_for_scalar(override_set: bool, is_global_default: bool) -> ResolutionSource {
573 if override_set {
574 ResolutionSource::RequestOverride
575 } else if is_global_default {
576 ResolutionSource::GlobalDefault
577 } else {
578 ResolutionSource::PlaybookDefault
579 }
580}
581
582const fn tier_source(override_set: bool, is_global_default: bool) -> ResolutionSource {
583 source_for_scalar(override_set, is_global_default)
584}
585
586#[cfg(test)]
587#[allow(
588 clippy::unwrap_used,
589 clippy::expect_used,
590 clippy::panic,
591 clippy::indexing_slicing,
592 clippy::similar_names
593)]
594mod tests {
595 use super::*;
596
597 fn make_resolver() -> PlaybookResolver {
598 PlaybookResolver::from_playbooks(vec![
599 Playbook {
600 id: "tier1-static".to_string(),
601 target_class: TargetClass::ContentSite,
602 description: String::new(),
603 acquisition: AcquisitionDefaults {
604 mode: AcquisitionModeHint::Fast,
605 execution_mode: ExecutionMode::Http,
606 session_mode: SessionMode::Stateless,
607 telemetry_level: TelemetryLevel::Basic,
608 sticky_session_ttl_secs: None,
609 enable_warmup: false,
610 retry_budget: 3,
611 backoff_base_ms: 250,
612 },
613 proxy_preference: ProxyPreference::default_for(TargetClass::ContentSite),
614 pacing: PacingProfile::default_for(TargetClass::ContentSite),
615 escalation: EscalationStrategy::Capped {
616 ceiling: AcquisitionModeHint::Resilient,
617 },
618 },
619 Playbook {
620 id: "tier1-js".to_string(),
621 target_class: TargetClass::ContentSite,
622 description: String::new(),
623 acquisition: AcquisitionDefaults {
624 mode: AcquisitionModeHint::Resilient,
625 execution_mode: ExecutionMode::Browser,
626 session_mode: SessionMode::Sticky,
627 telemetry_level: TelemetryLevel::Standard,
628 sticky_session_ttl_secs: Some(600),
629 enable_warmup: true,
630 retry_budget: 5,
631 backoff_base_ms: 500,
632 },
633 proxy_preference: ProxyPreference::default_for(TargetClass::ContentSite),
634 pacing: PacingProfile::default_for(TargetClass::ContentSite),
635 escalation: EscalationStrategy::Capped {
636 ceiling: AcquisitionModeHint::Hostile,
637 },
638 },
639 Playbook {
640 id: "tier2-hostile".to_string(),
641 target_class: TargetClass::HighSecurity,
642 description: String::new(),
643 acquisition: AcquisitionDefaults::default_for(TargetClass::HighSecurity),
644 proxy_preference: ProxyPreference::default_for(TargetClass::HighSecurity),
645 pacing: PacingProfile::default_for(TargetClass::HighSecurity),
646 escalation: EscalationStrategy::Capped {
647 ceiling: AcquisitionModeHint::Hostile,
648 },
649 },
650 Playbook {
651 id: "unknown".to_string(),
652 target_class: TargetClass::Unknown,
653 description: String::new(),
654 acquisition: AcquisitionDefaults::default_for(TargetClass::Unknown),
655 proxy_preference: ProxyPreference::default_for(TargetClass::Unknown),
656 pacing: PacingProfile::default_for(TargetClass::Unknown),
657 escalation: EscalationStrategy::default(),
658 },
659 ])
660 .expect("resolver fixture is valid")
661 }
662
663 #[test]
664 fn duplicate_ids_rejected() {
665 let result = PlaybookResolver::from_playbooks(vec![
666 Playbook {
667 id: "dup".to_string(),
668 target_class: TargetClass::ContentSite,
669 description: String::new(),
670 acquisition: AcquisitionDefaults::default(),
671 proxy_preference: ProxyPreference::default(),
672 pacing: PacingProfile::default(),
673 escalation: EscalationStrategy::default(),
674 },
675 Playbook {
676 id: "dup".to_string(),
677 target_class: TargetClass::Api,
678 description: String::new(),
679 acquisition: AcquisitionDefaults::default(),
680 proxy_preference: ProxyPreference::default(),
681 pacing: PacingProfile::default(),
682 escalation: EscalationStrategy::default(),
683 },
684 ]);
685 assert!(matches!(result, Err(ValidationError::DuplicateId { .. })));
686 }
687
688 #[test]
689 fn invalid_playbook_rejected() {
690 let result = PlaybookResolver::from_playbooks(vec![Playbook {
691 id: "broken".to_string(),
692 target_class: TargetClass::ContentSite,
693 description: String::new(),
694 acquisition: AcquisitionDefaults {
695 retry_budget: 0,
696 ..AcquisitionDefaults::default()
697 },
698 proxy_preference: ProxyPreference::default(),
699 pacing: PacingProfile::default(),
700 escalation: EscalationStrategy::default(),
701 }]);
702 let err = result.expect_err("retry_budget 0 is invalid");
703 assert_eq!(err.field_path(), Some("acquisition.retry_budget"));
704 }
705
706 #[test]
707 fn request_override_wins_over_playbook_default() {
708 let resolver = make_resolver();
709 let overrides = PlaybookOverrides {
710 acquisition: AcquisitionOverrides {
711 retry_budget: Some(99),
712 ..AcquisitionOverrides::default()
713 },
714 ..PlaybookOverrides::default()
715 };
716 let resolved = resolver
717 .resolve(TargetClass::ContentSite, "tier1-static", &overrides)
718 .expect("resolve");
719 assert_eq!(resolved.acquisition.retry_budget, 99);
720 assert_eq!(
721 resolved.acquisition.retry_budget_source,
722 ResolutionSource::RequestOverride
723 );
724 }
725
726 #[test]
727 fn playbook_default_used_when_no_override() {
728 let resolver = make_resolver();
729 let resolved = resolver
730 .resolve(
731 TargetClass::ContentSite,
732 "tier1-js",
733 &PlaybookOverrides::default(),
734 )
735 .expect("resolve");
736 assert_eq!(resolved.acquisition.retry_budget, 5);
737 assert_eq!(
738 resolved.acquisition.retry_budget_source,
739 ResolutionSource::PlaybookDefault
740 );
741 assert!(resolved.acquisition.enable_warmup);
742 assert_eq!(
743 resolved.acquisition.enable_warmup_source,
744 ResolutionSource::PlaybookDefault
745 );
746 }
747
748 #[test]
749 fn global_default_used_when_no_playbook_matches() {
750 let resolver = make_resolver();
751 let resolved = resolver
752 .resolve(TargetClass::Api, "", &PlaybookOverrides::default())
753 .expect("resolve");
754 assert_eq!(resolved.playbook_id, "unknown");
755 assert_eq!(
756 resolved.acquisition.retry_budget_source,
757 ResolutionSource::GlobalDefault
758 );
759 }
760
761 #[test]
762 fn unknown_explicit_id_returns_error() {
763 let resolver = make_resolver();
764 let err = resolver
765 .resolve(
766 TargetClass::ContentSite,
767 "nope",
768 &PlaybookOverrides::default(),
769 )
770 .expect_err("unknown id");
771 assert!(matches!(err, ValidationError::UnknownPlaybook { .. }));
772 }
773
774 #[test]
775 fn override_replaces_proxy_preference_whole() {
776 let resolver = make_resolver();
777 let proxy = ProxyPreference {
778 preferred_protocol: "socks5".to_string(),
779 require_sticky: true,
780 require_residential: true,
781 max_latency_ms: Some(300),
782 };
783 let overrides = PlaybookOverrides {
784 proxy_preference: Some(proxy.clone()),
785 ..PlaybookOverrides::default()
786 };
787 let resolved = resolver
788 .resolve(TargetClass::ContentSite, "tier1-static", &overrides)
789 .expect("resolve");
790 assert_eq!(resolved.proxy_preference, proxy);
791 assert_eq!(
792 resolved.proxy_preference_source,
793 ResolutionSource::RequestOverride
794 );
795 }
796
797 #[test]
798 fn override_replaces_pacing_whole() {
799 let resolver = make_resolver();
800 let pacing = PacingProfile {
801 rate_limit_rps: 7.5,
802 jitter_pct: 0.30,
803 min_request_interval_ms: 150,
804 };
805 let overrides = PlaybookOverrides {
806 pacing: Some(pacing.clone()),
807 ..PlaybookOverrides::default()
808 };
809 let resolved = resolver
810 .resolve(TargetClass::ContentSite, "tier1-static", &overrides)
811 .expect("resolve");
812 assert_eq!(resolved.pacing, pacing);
813 assert_eq!(resolved.pacing_source, ResolutionSource::RequestOverride);
814 }
815
816 #[test]
817 fn resolve_optional_falls_through_to_target_class_default() {
818 let resolver = make_resolver();
819 let resolved = resolver
820 .resolve_optional(
821 TargetClass::HighSecurity,
822 None,
823 &PlaybookOverrides::default(),
824 )
825 .expect("resolve");
826 assert_eq!(resolved.playbook_id, "tier2-hostile");
827 }
828
829 #[test]
830 fn to_acquisition_policy_propagates_fields() {
831 let resolver = make_resolver();
832 let resolved = resolver
833 .resolve(
834 TargetClass::ContentSite,
835 "tier1-js",
836 &PlaybookOverrides::default(),
837 )
838 .expect("resolve");
839 let policy = resolved.to_acquisition_policy();
840 assert_eq!(policy.retry_budget, 5);
841 assert_eq!(policy.backoff_base_ms, 500);
842 assert!(policy.enable_warmup);
843 assert!(policy.sticky_session);
844 }
845}