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