1use anyhow::{bail, Context, Result};
10use serde::{Deserialize, Serialize};
11use std::collections::BTreeMap;
12use std::fmt;
13
14#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum OnMissing {
18 #[default]
20 Error,
21 Empty,
23}
24
25impl OnMissing {
26 pub fn parse(s: &str) -> Result<Self> {
27 match s.trim().to_ascii_lowercase().as_str() {
28 "error" | "fail" | "strict" => Ok(Self::Error),
29 "empty" | "empty_frame" | "optional" => Ok(Self::Empty),
30 other => bail!(
31 "E_RBT_ON_MISSING: unknown on_missing '{other}' (expected error|empty)"
32 ),
33 }
34 }
35
36 pub fn as_str(self) -> &'static str {
37 match self {
38 Self::Error => "error",
39 Self::Empty => "empty",
40 }
41 }
42}
43
44impl fmt::Display for OnMissing {
45 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46 f.write_str(self.as_str())
47 }
48}
49
50#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
52pub struct RunScope {
53 #[serde(default)]
55 pub vars: BTreeMap<String, String>,
56 #[serde(default)]
59 pub contract_version: Option<String>,
60 #[serde(default)]
62 pub skip_if_fingerprint_match: bool,
63 #[serde(default = "default_true")]
65 pub write_receipt: bool,
66 #[serde(default)]
68 pub run_id: Option<String>,
69}
70
71fn default_true() -> bool {
72 true
73}
74
75impl RunScope {
76 pub fn new() -> Self {
77 Self {
78 write_receipt: true,
79 ..Default::default()
80 }
81 }
82
83 pub fn with_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
84 self.vars.insert(key.into(), value.into());
85 self
86 }
87
88 pub fn extend_from_kv_pairs<'a, I>(&mut self, pairs: I) -> Result<()>
90 where
91 I: IntoIterator<Item = &'a str>,
92 {
93 for raw in pairs {
94 let raw = raw.trim();
95 if raw.is_empty() {
96 continue;
97 }
98 let (k, v) = raw
99 .split_once('=')
100 .with_context(|| {
101 format!(
102 "E_RBT_VAR: expected key=value, got '{raw}'. Example: --var report_date=2026-07-29"
103 )
104 })?;
105 let k = k.trim();
106 if k.is_empty() {
107 bail!("E_RBT_VAR: empty key in '{raw}'");
108 }
109 self.vars.insert(k.to_string(), v.trim().to_string());
110 }
111 Ok(())
112 }
113
114 pub fn extend_from_env(&mut self) {
116 for (key, val) in std::env::vars() {
117 let Some(rest) = key.strip_prefix("RBT_VAR_") else {
118 continue;
119 };
120 if rest.is_empty() {
121 continue;
122 }
123 let var_name = rest.to_ascii_lowercase();
124 self.vars.entry(var_name).or_insert(val);
125 }
126 if let Ok(csv) = std::env::var("RBT_VARS") {
127 let _ = self.extend_from_kv_pairs(csv.split(',').map(str::trim).filter(|s| !s.is_empty()));
128 }
129 }
130
131 pub fn expand_template(&self, input: &str) -> String {
133 expand_braced_vars(input, &self.vars)
134 }
135
136 pub fn effective_require_partitions(
141 &self,
142 partition_by: &[String],
143 frontmatter: &std::collections::HashMap<String, String>,
144 ) -> std::collections::HashMap<String, String> {
145 let mut out = std::collections::HashMap::new();
146 for (k, v) in frontmatter {
147 out.insert(k.clone(), self.expand_template(v));
148 }
149 for key in partition_by {
150 if let Some(v) = self.vars.get(key) {
151 out.insert(key.clone(), v.clone());
152 }
153 }
154 out
157 }
158
159 pub fn scope_key(&self) -> String {
161 let mut parts: Vec<String> = self
162 .vars
163 .iter()
164 .map(|(k, v)| format!("{k}={v}"))
165 .collect();
166 parts.sort();
167 let body = parts.join("&");
168 format!("s{:016x}", fnv1a64(body.as_bytes()))
169 }
170
171 pub fn resolve_run_id(&self) -> String {
172 if let Some(id) = &self.run_id {
173 if !id.trim().is_empty() {
174 return id.trim().to_string();
175 }
176 }
177 let ts = std::time::SystemTime::now()
178 .duration_since(std::time::UNIX_EPOCH)
179 .map(|d| d.as_millis())
180 .unwrap_or(0);
181 format!("run_{ts}_{:08x}", fnv1a64(self.scope_key().as_bytes()) as u32)
182 }
183}
184
185pub fn expand_braced_vars(input: &str, vars: &BTreeMap<String, String>) -> String {
187 let mut out = String::with_capacity(input.len());
188 let bytes = input.as_bytes();
189 let mut i = 0;
190 while i < bytes.len() {
191 if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' {
192 if let Some(end) = input[i + 2..].find('}') {
193 let key = &input[i + 2..i + 2 + end];
194 if let Some(v) = vars.get(key) {
195 out.push_str(v);
196 } else {
197 out.push_str(&input[i..i + 3 + end]);
198 }
199 i += 3 + end;
200 continue;
201 }
202 }
203 if bytes[i] == b'{' {
204 if let Some(end) = input[i + 1..].find('}') {
205 let key = &input[i + 1..i + 1 + end];
206 if is_simple_ident(key) {
208 if let Some(v) = vars.get(key) {
209 out.push_str(v);
210 i += 2 + end;
211 continue;
212 }
213 }
214 }
215 }
216 out.push(bytes[i] as char);
217 i += 1;
218 }
219 out
220}
221
222fn is_simple_ident(s: &str) -> bool {
223 let mut chars = s.chars();
224 match chars.next() {
225 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
226 _ => return false,
227 }
228 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
229}
230
231pub fn fnv1a64(data: &[u8]) -> u64 {
233 const OFFSET: u64 = 0xcbf29ce484222325;
234 const PRIME: u64 = 0x100000001b3;
235 let mut hash = OFFSET;
236 for b in data {
237 hash ^= u64::from(*b);
238 hash = hash.wrapping_mul(PRIME);
239 }
240 hash
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 #[test]
248 fn expand_braced_and_dollar() {
249 let mut vars = BTreeMap::new();
250 vars.insert("report_date".into(), "2026-07-29".into());
251 vars.insert("domain".into(), "acme.com".into());
252 assert_eq!(
253 expand_braced_vars("d={domain}/dt={report_date}", &vars),
254 "d=acme.com/dt=2026-07-29"
255 );
256 assert_eq!(
257 expand_braced_vars("x=${report_date}/y", &vars),
258 "x=2026-07-29/y"
259 );
260 }
261
262 #[test]
263 fn merge_partitions_run_wins() {
264 let scope = RunScope::new()
265 .with_var("report_date", "2026-07-29")
266 .with_var("domain", "acme.com");
267 let mut fm = std::collections::HashMap::new();
268 fm.insert("report_date".into(), "static".into());
269 let pb = vec!["domain".into(), "report_date".into(), "run_id".into()];
270 let eff = scope.effective_require_partitions(&pb, &fm);
271 assert_eq!(eff.get("report_date").map(String::as_str), Some("2026-07-29"));
272 assert_eq!(eff.get("domain").map(String::as_str), Some("acme.com"));
273 assert!(!eff.contains_key("run_id"));
274 }
275
276 #[test]
277 fn parse_kv_pairs() {
278 let mut s = RunScope::new();
279 s.extend_from_kv_pairs(["report_date=2026-07-29", "run_id=r1"])
280 .unwrap();
281 assert_eq!(s.vars.get("run_id").map(String::as_str), Some("r1"));
282 }
283}