1use std::time::Duration;
14
15use serde::{Deserialize, Serialize};
16
17#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
20#[serde(deny_unknown_fields)]
21pub struct Limits {
22 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub deadline: Option<String>,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub stuck: Option<String>,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub cost_usd: Option<f64>,
28}
29
30impl Limits {
31 pub fn is_empty(&self) -> bool {
32 self.deadline.is_none() && self.stuck.is_none() && self.cost_usd.is_none()
33 }
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Stuck {
39 Off,
40 After(Duration),
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum Source {
47 BuiltIn,
48 Global,
49 Fleet,
50 Agent,
51 Flag,
52}
53
54impl Source {
55 pub fn label(self) -> &'static str {
56 match self {
57 Source::BuiltIn => "built-in default",
58 Source::Global => "~/.mur/config.yaml",
59 Source::Fleet => "fleet.yaml",
60 Source::Agent => "profile.yaml",
61 Source::Flag => "command-line flag",
62 }
63 }
64}
65
66#[derive(Debug, Clone, PartialEq)]
67pub struct Resolved<T> {
68 pub value: T,
69 pub source: Source,
70}
71
72#[derive(Debug, Clone, PartialEq)]
73pub struct ResolvedLimits {
74 pub deadline: Resolved<Option<Duration>>,
75 pub stuck: Resolved<Stuck>,
76 pub cost_usd: Resolved<Option<f64>>,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum Scope {
82 FleetRun,
83 SingleTask,
84}
85
86pub const DEFAULT_STUCK: Duration = Duration::from_secs(10 * 60);
88pub const DEFAULT_DEADLINE_FLEET: Duration = Duration::from_secs(60 * 60);
89pub const DEFAULT_DEADLINE_TASK: Duration = Duration::from_secs(30 * 60);
90
91pub fn parse_duration(s: &str) -> Option<Duration> {
94 let s = s.trim();
95 if s.is_empty() {
96 return None;
97 }
98 if let Ok(n) = s.parse::<u64>() {
99 return Some(Duration::from_secs(n));
100 }
101 let mut total: u64 = 0;
102 let mut num = String::new();
103 let mut saw_unit = false;
104 for c in s.chars() {
105 if c.is_ascii_digit() {
106 num.push(c);
107 continue;
108 }
109 let n: u64 = num.parse().ok()?;
110 num.clear();
111 let mult = match c {
112 's' => 1,
113 'm' => 60,
114 'h' => 3600,
115 'd' => 86_400,
116 _ => return None,
117 };
118 total = total.checked_add(n.checked_mul(mult)?)?;
119 saw_unit = true;
120 }
121 if !num.is_empty() || !saw_unit {
122 return None;
123 }
124 Some(Duration::from_secs(total))
125}
126
127fn parse_stuck(s: &str) -> Option<Stuck> {
128 if s.trim().eq_ignore_ascii_case("off") {
129 return Some(Stuck::Off);
130 }
131 parse_duration(s).map(Stuck::After)
132}
133
134pub fn validate(l: &Limits) -> Result<(), String> {
136 if let Some(d) = &l.deadline
137 && parse_duration(d).is_none()
138 {
139 return Err(format!(
140 "limits.deadline: `{d}` is not a duration (30s, 5m, 2h, 1h30m, 1d)"
141 ));
142 }
143 if let Some(s) = &l.stuck
144 && parse_stuck(s).is_none()
145 {
146 return Err(format!("limits.stuck: `{s}` is not a duration or `off`"));
147 }
148 if let Some(c) = l.cost_usd
149 && !(c.is_finite() && c >= 0.0)
150 {
151 return Err(format!(
152 "limits.cost_usd: `{c}` must be a non-negative number"
153 ));
154 }
155 Ok(())
156}
157
158pub fn resolve(
162 scope: Scope,
163 global: &Limits,
164 fleet: Option<&Limits>,
165 agent: Option<&Limits>,
166 flags: &Limits,
167) -> Result<ResolvedLimits, String> {
168 for (l, who) in [
169 (Some(flags), "flag"),
170 (agent, "profile.yaml"),
171 (fleet, "fleet.yaml"),
172 (Some(global), "config.yaml"),
173 ] {
174 if let Some(l) = l {
175 validate(l).map_err(|e| format!("{who}: {e}"))?;
176 }
177 }
178 let layers: [(Option<&Limits>, Source); 4] = [
180 (Some(flags), Source::Flag),
181 (agent, Source::Agent),
182 (fleet, Source::Fleet),
183 (Some(global), Source::Global),
184 ];
185 let pick = |get: &dyn Fn(&Limits) -> bool| -> Option<(&Limits, Source)> {
186 layers
187 .iter()
188 .find_map(|(l, src)| l.filter(|l| get(l)).map(|l| (l, *src)))
189 };
190
191 let deadline = match pick(&|l| l.deadline.is_some()) {
192 Some((l, src)) => Resolved {
193 value: l.deadline.as_deref().and_then(parse_duration),
194 source: src,
195 },
196 None => Resolved {
197 value: Some(match scope {
198 Scope::FleetRun => DEFAULT_DEADLINE_FLEET,
199 Scope::SingleTask => DEFAULT_DEADLINE_TASK,
200 }),
201 source: Source::BuiltIn,
202 },
203 };
204 let stuck = match pick(&|l| l.stuck.is_some()) {
205 Some((l, src)) => Resolved {
206 value: l
207 .stuck
208 .as_deref()
209 .and_then(parse_stuck)
210 .unwrap_or(Stuck::After(DEFAULT_STUCK)),
211 source: src,
212 },
213 None => Resolved {
214 value: Stuck::After(DEFAULT_STUCK),
215 source: Source::BuiltIn,
216 },
217 };
218 let cost_usd = match pick(&|l| l.cost_usd.is_some()) {
219 Some((l, src)) => Resolved {
220 value: l.cost_usd,
221 source: src,
222 },
223 None => Resolved {
224 value: None,
225 source: Source::BuiltIn,
226 },
227 };
228 Ok(ResolvedLimits {
229 deadline,
230 stuck,
231 cost_usd,
232 })
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238
239 fn l(deadline: Option<&str>, stuck: Option<&str>, cost: Option<f64>) -> Limits {
240 Limits {
241 deadline: deadline.map(str::to_string),
242 stuck: stuck.map(str::to_string),
243 cost_usd: cost,
244 }
245 }
246
247 #[test]
248 fn durations_parse_the_spec_grammar_and_nothing_else() {
249 assert_eq!(parse_duration("30s"), Some(Duration::from_secs(30)));
250 assert_eq!(parse_duration("5m"), Some(Duration::from_secs(300)));
251 assert_eq!(parse_duration("2h"), Some(Duration::from_secs(7200)));
252 assert_eq!(parse_duration("1d"), Some(Duration::from_secs(86_400)));
253 assert_eq!(parse_duration("1h30m"), Some(Duration::from_secs(5400)));
254 assert_eq!(parse_duration(" 90 "), Some(Duration::from_secs(90)));
255 for bad in ["", "off", "2 hours", "1h3", "h", "-5m", "1.5h"] {
256 assert_eq!(parse_duration(bad), None, "{bad:?} must not parse");
257 }
258 }
259
260 #[test]
261 fn narrowest_scope_wins_per_key_and_never_combines() {
262 let global = l(Some("4h"), Some("20m"), Some(50.0));
263 let fleet = l(Some("2h"), None, None);
264 let agent = l(None, Some("off"), None);
265 let r = resolve(
266 Scope::FleetRun,
267 &global,
268 Some(&fleet),
269 Some(&agent),
270 &Limits::default(),
271 )
272 .unwrap();
273 assert_eq!(r.deadline.value, Some(Duration::from_secs(7200)));
274 assert_eq!(
275 r.deadline.source,
276 Source::Fleet,
277 "fleet set it, agent did not"
278 );
279 assert_eq!(r.stuck.value, Stuck::Off);
280 assert_eq!(r.stuck.source, Source::Agent);
281 assert_eq!(r.cost_usd.value, Some(50.0));
282 assert_eq!(r.cost_usd.source, Source::Global, "nobody narrower set it");
283
284 let flags = l(Some("10m"), None, None);
286 let r = resolve(Scope::FleetRun, &global, Some(&fleet), Some(&agent), &flags).unwrap();
287 assert_eq!(r.deadline.source, Source::Flag);
288 assert_eq!(r.stuck.source, Source::Agent);
289 }
290
291 #[test]
292 fn built_in_defaults_fill_what_no_scope_set_and_say_so() {
293 let none = Limits::default();
294 let r = resolve(Scope::FleetRun, &none, None, None, &none).unwrap();
295 assert_eq!(r.deadline.value, Some(DEFAULT_DEADLINE_FLEET));
296 assert_eq!(r.deadline.source, Source::BuiltIn);
297 assert_eq!(r.stuck.value, Stuck::After(DEFAULT_STUCK));
298 assert_eq!(r.cost_usd.value, None);
299 let r = resolve(Scope::SingleTask, &none, None, None, &none).unwrap();
300 assert_eq!(r.deadline.value, Some(DEFAULT_DEADLINE_TASK));
301 }
302
303 #[test]
304 fn a_bad_value_is_an_error_that_names_the_key_and_the_scope() {
305 let bad = l(Some("soon"), None, None);
306 let e = resolve(
307 Scope::FleetRun,
308 &Limits::default(),
309 Some(&bad),
310 None,
311 &Limits::default(),
312 )
313 .unwrap_err();
314 assert!(
315 e.contains("fleet.yaml") && e.contains("limits.deadline") && e.contains("soon"),
316 "{e}"
317 );
318 let e = validate(&l(None, Some("sometimes"), None)).unwrap_err();
319 assert!(e.contains("limits.stuck"), "{e}");
320 let e = validate(&l(None, None, Some(-1.0))).unwrap_err();
321 assert!(e.contains("limits.cost_usd"), "{e}");
322 }
323
324 #[test]
325 fn unknown_keys_are_rejected_at_load() {
326 let e = serde_yaml_ng::from_str::<Limits>("deadline: 1h\nmax_iterations: 5\n").unwrap_err();
327 assert!(e.to_string().contains("max_iterations"), "{e}");
328 let ok: Limits = serde_yaml_ng::from_str("stuck: off\n").unwrap();
329 assert_eq!(ok.stuck.as_deref(), Some("off"));
330 assert!(serde_yaml_ng::to_string(&Limits::default()).unwrap().trim() == "{}");
331 }
332}