1use serde_json::Value;
9
10use crate::json_to_lua::json_to_lua;
11
12pub const MIN_CONCURRENCY: usize = 1;
15pub const MAX_CONCURRENCY: usize = 64;
16pub const DEFAULT_EVENTS_LIMIT: u64 = 50;
17pub const MAX_EVENTS_LIMIT: u64 = 500;
18
19pub fn parse_concurrency(args: &Value) -> Result<Option<usize>, String> {
27 let Some(v) = args.get("concurrency") else {
28 return Ok(None);
29 };
30 if v.is_null() {
31 return Ok(None);
32 }
33 let n = v
34 .as_u64()
35 .ok_or_else(|| format!("'concurrency' must be a positive integer, got {v}"))?;
36 if !(MIN_CONCURRENCY as u64..=MAX_CONCURRENCY as u64).contains(&n) {
37 return Err(format!(
38 "'concurrency' must be between {MIN_CONCURRENCY} and {MAX_CONCURRENCY}, got {n}"
39 ));
40 }
41 Ok(Some(n as usize))
42}
43
44#[derive(Debug, Clone)]
49pub struct EventsFilter {
50 pub offset: u64,
51 pub events_limit: u64,
52 pub types: Option<Vec<String>>,
53 pub agent_id: Option<String>,
54}
55
56impl EventsFilter {
57 pub fn from_args(args: &Value) -> Self {
59 Self {
60 offset: parse_events_offset(args),
61 events_limit: parse_events_limit(args),
62 types: parse_events_types(args),
63 agent_id: parse_events_agent_id(args),
64 }
65 }
66
67 pub fn matches(&self, event: &Value) -> bool {
70 let type_ok = self.types.as_ref().is_none_or(|ts| {
71 event
72 .get("type")
73 .and_then(|t| t.as_str())
74 .map(|t| ts.iter().any(|x| x == t))
75 .unwrap_or(false)
76 });
77 let agent_ok = self.agent_id.as_ref().is_none_or(|aid| {
78 event
79 .get("agent_id")
80 .and_then(|a| a.as_str())
81 .map(|a| a == aid)
82 .unwrap_or(false)
83 });
84 type_ok && agent_ok
85 }
86}
87
88pub fn parse_events_offset(args: &Value) -> u64 {
90 args.get("offset").and_then(|v| v.as_u64()).unwrap_or(0)
91}
92
93pub fn parse_events_limit(args: &Value) -> u64 {
95 args.get("events_limit")
96 .and_then(|v| v.as_u64())
97 .unwrap_or(DEFAULT_EVENTS_LIMIT)
98 .clamp(1, MAX_EVENTS_LIMIT)
99}
100
101pub fn parse_events_types(args: &Value) -> Option<Vec<String>> {
103 let v = args.get("types")?;
104 if v.is_null() {
105 return None;
106 }
107 let arr = v.as_array()?;
108 let out: Vec<String> = arr
109 .iter()
110 .filter_map(|t| t.as_str().map(String::from))
111 .collect();
112 if out.is_empty() {
113 None
114 } else {
115 Some(out)
116 }
117}
118
119pub fn parse_events_agent_id(args: &Value) -> Option<String> {
121 let v = args.get("agent_id")?;
122 if v.is_null() {
123 return None;
124 }
125 v.as_str().map(String::from)
126}
127
128pub fn paginate<T: Clone>(items: &[T], offset: u64, limit: u64) -> (Vec<T>, u64, Option<u64>) {
134 let total = items.len() as u64;
135 let page: Vec<T> = items
136 .iter()
137 .skip(offset as usize)
138 .take(limit as usize)
139 .cloned()
140 .collect();
141 let next_offset = if offset + (page.len() as u64) < total {
142 Some(offset + page.len() as u64)
143 } else {
144 None
145 };
146 (page, total, next_offset)
147}
148
149pub fn paginate_cursor<'a, T, F>(
155 items: &'a [T],
156 cursor: Option<&str>,
157 limit: usize,
158 key: F,
159) -> (Vec<&'a T>, Option<String>)
160where
161 F: Fn(&T) -> &str,
162{
163 let start = match cursor {
164 Some(c) => items
165 .iter()
166 .position(|item| key(item) == c)
167 .map(|i| i + 1)
168 .unwrap_or(0),
169 None => 0,
170 };
171
172 let page: Vec<&T> = items.iter().skip(start).take(limit).collect();
173 let next_cursor = if start + page.len() < items.len() {
174 page.last().map(|item| key(item).to_string())
175 } else {
176 None
177 };
178
179 (page, next_cursor)
180}
181
182pub const DEFAULT_LIST_LIMIT: u64 = 20;
186pub const MAX_LIST_LIMIT: u64 = 100;
188pub const STATUS_FILTERS: &[&str] = &["completed", "failed", "cancelled"];
190
191pub fn parse_list_limit(args: &Value) -> Result<u64, String> {
193 let Some(v) = args.get("limit") else {
194 return Ok(DEFAULT_LIST_LIMIT);
195 };
196 if v.is_null() {
197 return Ok(DEFAULT_LIST_LIMIT);
198 }
199 let n = v
200 .as_u64()
201 .ok_or_else(|| format!("'limit' must be a positive integer, got {v}"))?;
202 if !(1..=MAX_LIST_LIMIT).contains(&n) {
203 return Err(format!("'limit' must be between 1 and {MAX_LIST_LIMIT}, got {n}"));
204 }
205 Ok(n)
206}
207
208pub fn parse_status_filter(args: &Value) -> Result<Option<String>, String> {
212 let Some(v) = args.get("status_filter") else {
213 return Ok(None);
214 };
215 if v.is_null() {
216 return Ok(None);
217 }
218 let s = v
219 .as_str()
220 .ok_or_else(|| format!("'status_filter' must be a string, got {v}"))?;
221 let lower = s.to_lowercase();
222 if !STATUS_FILTERS.contains(&lower.as_str()) {
223 return Err(format!(
224 "'status_filter' must be one of completed|failed|cancelled, got {s}"
225 ));
226 }
227 Ok(Some(lower))
228}
229
230pub fn parse_cursor(args: &Value) -> Option<String> {
232 let v = args.get("cursor")?;
233 if v.is_null() {
234 return None;
235 }
236 v.as_str().filter(|s| !s.is_empty()).map(String::from)
237}
238
239pub fn extract_user_args(args: &Value) -> Option<Value> {
245 let v = args.get("args")?;
246 if v.is_null() {
247 return None;
248 }
249 Some(v.clone())
250}
251
252pub fn inject_args_globals(lua_source: &str, user_args: Option<&Value>) -> String {
256 let Some(args) = user_args else {
257 return lua_source.to_string();
258 };
259 let lua_expr = json_to_lua(args);
260 format!("_G._args = {lua_expr}\n{lua_source}")
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use serde_json::json;
267
268 #[test]
269 fn concurrency_absent_returns_none() {
270 assert_eq!(parse_concurrency(&json!({})).unwrap(), None);
271 }
272
273 #[test]
274 fn concurrency_null_returns_none() {
275 assert_eq!(parse_concurrency(&json!({"concurrency": null})).unwrap(), None);
276 }
277
278 #[test]
279 fn concurrency_valid() {
280 assert_eq!(parse_concurrency(&json!({"concurrency": 8})).unwrap(), Some(8));
281 }
282
283 #[test]
284 fn concurrency_out_of_range() {
285 assert!(parse_concurrency(&json!({"concurrency": 0})).is_err());
286 assert!(parse_concurrency(&json!({"concurrency": 65})).is_err());
287 }
288
289 #[test]
290 fn concurrency_non_integer() {
291 assert!(parse_concurrency(&json!({"concurrency": "fast"})).is_err());
292 }
293
294 #[test]
295 fn events_filter_defaults() {
296 let f = EventsFilter::from_args(&json!({}));
297 assert_eq!(f.offset, 0);
298 assert_eq!(f.events_limit, DEFAULT_EVENTS_LIMIT);
299 assert_eq!(f.types, None);
300 assert_eq!(f.agent_id, None);
301 }
302
303 #[test]
304 fn events_filter_parsed() {
305 let f = EventsFilter::from_args(&json!({
306 "offset": 10,
307 "events_limit": 5,
308 "types": ["agent_started", "agent_done"],
309 "agent_id": "abc-123"
310 }));
311 assert_eq!(f.offset, 10);
312 assert_eq!(f.events_limit, 5);
313 assert_eq!(f.types.as_ref().unwrap().len(), 2);
314 assert_eq!(f.agent_id.as_ref().unwrap(), "abc-123");
315 }
316
317 #[test]
318 fn events_limit_clamped() {
319 assert_eq!(
320 parse_events_limit(&json!({"events_limit": 0})),
321 1
322 );
323 assert_eq!(
324 parse_events_limit(&json!({"events_limit": 9999})),
325 MAX_EVENTS_LIMIT
326 );
327 }
328
329 #[test]
330 fn events_filter_matches() {
331 let f = EventsFilter::from_args(&json!({
332 "types": ["agent_done"],
333 "agent_id": "a1"
334 }));
335 assert!(f.matches(&json!({"type": "agent_done", "agent_id": "a1"})));
336 assert!(!f.matches(&json!({"type": "agent_started", "agent_id": "a1"})));
337 assert!(!f.matches(&json!({"type": "agent_done", "agent_id": "a2"})));
338 }
339
340 #[test]
341 fn paginate_basic() {
342 let items: Vec<i32> = (0..10).collect();
343 let (page, total, next) = paginate(&items, 2, 3);
344 assert_eq!(page, vec![2, 3, 4]);
345 assert_eq!(total, 10);
346 assert_eq!(next, Some(5));
347 }
348
349 #[test]
350 fn paginate_last_page() {
351 let items: Vec<i32> = (0..5).collect();
352 let (page, total, next) = paginate(&items, 3, 10);
353 assert_eq!(page, vec![3, 4]);
354 assert_eq!(total, 5);
355 assert_eq!(next, None);
356 }
357
358 #[test]
359 fn paginate_cursor_basic() {
360 let items = vec!["a", "b", "c", "d", "e"];
361 let (page, next) = paginate_cursor(&items, None, 2, |s| *s);
362 assert_eq!(page, vec![&"a", &"b"]);
363 assert_eq!(next, Some("b".to_string()));
364
365 let (page2, next2) = paginate_cursor(&items, Some("b"), 2, |s| *s);
366 assert_eq!(page2, vec![&"c", &"d"]);
367 assert_eq!(next2, Some("d".to_string()));
368
369 let (page3, next3) = paginate_cursor(&items, Some("d"), 2, |s| *s);
370 assert_eq!(page3, vec![&"e"]);
371 assert_eq!(next3, None);
372 }
373
374 #[test]
375 fn extract_user_args_missing() {
376 assert!(extract_user_args(&json!({})).is_none());
377 }
378
379 #[test]
380 fn extract_user_args_null_is_none() {
381 assert!(extract_user_args(&json!({"args": null})).is_none());
382 }
383
384 #[test]
385 fn extract_user_args_object() {
386 let v = extract_user_args(&json!({"args": {"topic": "rust"}})).unwrap();
387 assert_eq!(v["topic"], "rust");
388 }
389
390 #[test]
391 fn inject_no_args_returns_source_unchanged() {
392 let src = "function main() end";
393 assert_eq!(inject_args_globals(src, None), src);
394 }
395
396 #[test]
397 fn inject_prepends_global_assignment() {
398 let src = "function main() end";
399 let out = inject_args_globals(src, Some(&json!({"topic": "rust"})));
400 assert!(out.starts_with("_G._args = {topic = \"rust\"}\n"));
401 assert!(out.ends_with(src));
402 }
403
404 #[test]
407 fn list_limit_default() {
408 assert_eq!(parse_list_limit(&json!({})).unwrap(), DEFAULT_LIST_LIMIT);
409 }
410
411 #[test]
412 fn list_limit_null_is_default() {
413 assert_eq!(parse_list_limit(&json!({"limit": null})).unwrap(), DEFAULT_LIST_LIMIT);
414 }
415
416 #[test]
417 fn list_limit_explicit() {
418 assert_eq!(parse_list_limit(&json!({"limit": 5})).unwrap(), 5);
419 }
420
421 #[test]
422 fn list_limit_at_bounds() {
423 assert_eq!(parse_list_limit(&json!({"limit": 1})).unwrap(), 1);
424 assert_eq!(parse_list_limit(&json!({"limit": MAX_LIST_LIMIT})).unwrap(), MAX_LIST_LIMIT);
425 }
426
427 #[test]
428 fn list_limit_rejects_zero() {
429 assert!(parse_list_limit(&json!({"limit": 0})).is_err());
430 }
431
432 #[test]
433 fn list_limit_rejects_over_max() {
434 assert!(parse_list_limit(&json!({"limit": MAX_LIST_LIMIT + 1})).is_err());
435 }
436
437 #[test]
438 fn list_limit_rejects_non_integer() {
439 assert!(parse_list_limit(&json!({"limit": "many"})).is_err());
440 }
441
442 #[test]
443 fn status_filter_absent_is_none() {
444 assert_eq!(parse_status_filter(&json!({})).unwrap(), None);
445 }
446
447 #[test]
448 fn status_filter_null_is_none() {
449 assert_eq!(parse_status_filter(&json!({"status_filter": null})).unwrap(), None);
450 }
451
452 #[test]
453 fn status_filter_case_insensitive() {
454 assert_eq!(parse_status_filter(&json!({"status_filter": "COMPLETED"})).unwrap(), Some("completed".into()));
455 }
456
457 #[test]
458 fn status_filter_rejects_invalid() {
459 assert!(parse_status_filter(&json!({"status_filter": "running"})).is_err());
460 }
461
462 #[test]
463 fn cursor_absent_is_none() {
464 assert!(parse_cursor(&json!({})).is_none());
465 }
466
467 #[test]
468 fn cursor_null_is_none() {
469 assert!(parse_cursor(&json!({"cursor": null})).is_none());
470 }
471
472 #[test]
473 fn cursor_empty_is_none() {
474 assert!(parse_cursor(&json!({"cursor": ""})).is_none());
475 }
476
477 #[test]
478 fn cursor_present() {
479 assert_eq!(parse_cursor(&json!({"cursor": "abc"})), Some("abc".into()));
480 }
481}