1use rustev_contract::run::{Charge, CostBound};
4use rustev_runtime::{SpendAttempt, SpendGate, SpendRefusal, SpendTicket, SpendUnit};
5
6use crate::journal::{BudgetKind, Reservation, SpendJournal, SpendRefusal as JournalRefusal};
7
8#[derive(Debug, Clone)]
12pub struct JournalGate {
13 journal: SpendJournal,
14 run_id: String,
15 run_cap_units: u64,
16}
17
18struct Ticket {
19 reservation: Reservation,
20 no_spend: bool,
21}
22
23impl JournalGate {
24 pub fn new(
25 journal: SpendJournal,
26 run_id: impl Into<String>,
27 run_cap_units: u64,
28 ) -> Result<Self, SpendRefusal> {
29 if journal.budget() != BudgetKind::Testing {
30 return Err(SpendRefusal::new("journal-budget"));
31 }
32 let run_id = run_id.into();
33 if run_id.is_empty()
34 || run_id.len() > 64
35 || !run_id
36 .bytes()
37 .all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
38 {
39 return Err(SpendRefusal::new("live-config"));
40 }
41 Ok(Self {
42 journal,
43 run_id,
44 run_cap_units,
45 })
46 }
47
48 pub(crate) fn journal(&self) -> &SpendJournal {
49 &self.journal
50 }
51
52 pub fn run_id(&self) -> &str {
53 &self.run_id
54 }
55}
56
57impl JournalGate {
58 fn reserve_attempt(
59 &self,
60 decision_id: &str,
61 attempt_id: &str,
62 bound: CostBound,
63 no_spend: bool,
64 ) -> Result<SpendTicket, SpendRefusal> {
65 if !decision_id.starts_with(&format!("live/{}/", self.run_id)) {
66 return Err(SpendRefusal::new("live-config"));
67 }
68 let units = match bound {
69 CostBound::Bounded { max_units } => max_units,
70 CostBound::Estimated { units } => units,
71 CostBound::Unknown => return Err(SpendRefusal::new("spend-exposure-unknown")),
72 };
73
74 let key = format!("{decision_id}/{attempt_id}");
77 let reservation = self
78 .journal
79 .reserve_checked(
80 &key,
81 if no_spend { 0 } else { units },
82 Some((&self.run_id, self.run_cap_units)),
83 )
84 .map_err(|e| {
85 SpendRefusal::new(match e {
86 JournalRefusal::RunCap => "run-cap-exceeded",
87 JournalRefusal::OverCap { .. } => "spend-cap-exceeded",
88 JournalRefusal::Journal(_) => "journal-io",
89 })
90 })?;
91 Ok(Box::new(Ticket {
92 reservation,
93 no_spend,
94 }))
95 }
96}
97
98impl SpendGate for JournalGate {
99 fn reserve(&self, attempt: &SpendAttempt) -> Result<SpendTicket, SpendRefusal> {
100 self.reserve_attempt(
101 &attempt.decision_id,
102 &attempt.attempt_id,
103 attempt.bound,
104 attempt.cost_unit == SpendUnit::None,
105 )
106 }
107 fn reserve_acquisition(
108 &self,
109 attempt: &rustev_runtime::AcquisitionSpendAttempt,
110 ) -> Result<SpendTicket, SpendRefusal> {
111 self.reserve_attempt(
112 &attempt.decision_id,
113 &attempt.attempt_id,
114 attempt.bound,
115 false,
116 )
117 }
118 fn settle(&self, ticket: SpendTicket, charge: &Charge) {
119 if let Ok(ticket) = ticket.downcast::<Ticket>() {
120 let charge = if ticket.no_spend {
122 match charge {
123 Charge::Observed { .. } => Charge::Observed { units: 0 },
124 Charge::Estimated { .. } => Charge::Estimated { units: 0 },
125 Charge::Unknown => Charge::Unknown,
126 }
127 } else {
128 *charge
129 };
130 self.journal.settle(ticket.reservation, charge);
131 }
132 }
133
134 fn release(&self, ticket: SpendTicket) {
135 if let Ok(ticket) = ticket.downcast::<Ticket>() {
136 self.journal.release(ticket.reservation);
137 }
138 }
139}