Skip to main content

luft_core/
params.rs

1//! Shared parameter parsing for workflow tool consumers.
2//!
3//! Both Loom's `tool-workflow` and Luft's `luft-mcp` expose tools that accept
4//! the same JSON arguments (`concurrency`, `offset`, `events_limit`, `types`,
5//! `agent_id`). This module provides a single source of truth for parsing
6//! and validating those parameters, eliminating ~140 lines of duplication.
7
8use serde_json::Value;
9
10use crate::json_to_lua::json_to_lua;
11
12// ── Constants ──────────────────────────────────────────────────────────
13
14pub 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
19// ── Concurrency ───────────────────────────────────────────────────────
20
21/// Parse the optional `concurrency` argument.
22///
23/// Returns `Ok(None)` when the argument is absent or null (caller decides
24/// the default). Returns `Ok(Some(n))` for a valid integer in
25/// `[MIN_CONCURRENCY, MAX_CONCURRENCY]`.
26pub 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// ── Events filter ─────────────────────────────────────────────────────
45
46/// Parsed event-query parameters: `offset`, `events_limit`, optional
47/// `types[]` filter, optional `agent_id` filter.
48#[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    /// Parse all four event-query parameters from a JSON args object.
58    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    /// Returns `true` when the event's `type` and `agent_id` match the
68    /// filter criteria (or when no filter is set).
69    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
88/// Parse `offset` (default 0).
89pub fn parse_events_offset(args: &Value) -> u64 {
90    args.get("offset").and_then(|v| v.as_u64()).unwrap_or(0)
91}
92
93/// Parse `events_limit` (default 50, clamped to `[1, 500]`).
94pub 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
101/// Parse `types[]` into a `Vec<String>`. Returns `None` when absent or empty.
102pub 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
119/// Parse `agent_id`. Returns `None` when absent or null.
120pub 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
128// ── Pagination ────────────────────────────────────────────────────────
129
130/// Apply offset/limit pagination to a slice and compute `next_offset`.
131///
132/// Returns `(page, total_matching, next_offset)`.
133pub 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
149/// Cursor-based pagination for list endpoints.
150///
151/// Finds the cursor in `items` (by comparing `key(item)` to `cursor`),
152/// skips it, takes `limit` items, and returns `(page, next_cursor)`.
153/// If `cursor` is `None`, starts from the beginning.
154pub 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
182// ── List-query parameters ─────────────────────────────────────────────
183
184/// Default page size for list endpoints.
185pub const DEFAULT_LIST_LIMIT: u64 = 20;
186/// Maximum page size for list endpoints.
187pub const MAX_LIST_LIMIT: u64 = 100;
188/// Valid status filter values (case-insensitive).
189pub const STATUS_FILTERS: &[&str] = &["completed", "failed", "cancelled"];
190
191/// Parse the `limit` argument for list endpoints (default 20, max 100).
192pub 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
208/// Parse the `status_filter` argument. Returns `None` when absent.
209///
210/// The returned string is lowercased and validated against [`STATUS_FILTERS`].
211pub 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
230/// Parse the `cursor` argument. Returns `None` when absent, null, or empty.
231pub 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
239// ── User args injection ───────────────────────────────────────────────
240
241/// Extract the optional `args` parameter from a JSON args object.
242///
243/// Returns `None` when `args` is absent or null.
244pub 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
252/// Prepend `_G._args = <lua_expr>` to the Lua source when user args are present.
253///
254/// When `user_args` is `None`, the source is returned unchanged.
255pub 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    // ── list-query params ────────────────────────────────────────────────
405
406    #[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}