Skip to main content

kaish_kernel/scheduler/
scatter.rs

1//! Scatter/Gather — Parallel pipeline execution.
2//!
3//! Scatter splits input into items and runs the pipeline in parallel.
4//! Gather collects the parallel results.
5//!
6//! # Example
7//!
8//! ```text
9//! cat urls.txt | scatter | fetch url=${ITEM} | gather
10//! ```
11//!
12//! This reads URLs, then for each URL runs `fetch` in parallel,
13//! then collects all results.
14
15use std::sync::Arc;
16use std::sync::atomic::{AtomicBool, Ordering};
17use std::time::Duration;
18
19use tokio::sync::Semaphore;
20use tracing::Instrument;
21
22use crate::ast::{Command, Value};
23use crate::dispatch::CommandDispatcher;
24use crate::duration::parse_duration;
25use crate::interpreter::ExecResult;
26use crate::tools::{ExecContext, ToolRegistry};
27
28use super::pipeline::PipelineRunner;
29
30/// Options for scatter operation.
31#[derive(Debug, Clone)]
32pub struct ScatterOptions {
33    /// Variable name to bind each item to (default: "ITEM").
34    pub var_name: String,
35    /// Maximum parallelism (default: 8).
36    pub limit: usize,
37    /// Per-worker timeout. When `Some`, each worker is cancelled after this
38    /// duration; the worker's external children get SIGTERM/SIGKILL and the
39    /// `ScatterResult.timed_out` flag is set.
40    pub timeout: Option<Duration>,
41}
42
43/// Options for gather operation.
44#[derive(Debug, Clone)]
45pub struct GatherOptions {
46    /// Show progress indicator.
47    pub progress: bool,
48    /// Take first N results and cancel rest (0 = all).
49    pub first: usize,
50    /// Output format: "json" or "lines".
51    pub format: String,
52}
53
54impl Default for ScatterOptions {
55    fn default() -> Self {
56        Self {
57            var_name: "ITEM".to_string(),
58            limit: 8,
59            timeout: None,
60        }
61    }
62}
63
64impl Default for GatherOptions {
65    fn default() -> Self {
66        Self {
67            progress: false,
68            first: 0,
69            format: "lines".to_string(),
70        }
71    }
72}
73
74/// Result from a single scatter worker.
75#[derive(Debug, Clone)]
76pub struct ScatterResult {
77    /// The input item that was processed.
78    pub item: String,
79    /// The execution result.
80    pub result: ExecResult,
81    /// Whether the worker was cancelled by the per-worker `--timeout`.
82    pub timed_out: bool,
83}
84
85/// Runs scatter/gather pipelines.
86///
87/// Uses a single dispatcher for sequential stages (pre_scatter, post_gather),
88/// and forks it per parallel worker via [`CommandDispatcher::fork`]. Each
89/// worker gets its own subkernel with snapshotted session state so they can
90/// run concurrently without racing on scope/cwd/aliases.
91pub struct ScatterGatherRunner {
92    tools: Arc<ToolRegistry>,
93    /// Full dispatch chain for sequential stages (pre_scatter, post_gather).
94    /// Parallel workers fork from this dispatcher.
95    sequential_dispatcher: Arc<dyn CommandDispatcher>,
96}
97
98impl ScatterGatherRunner {
99    /// Create a new scatter/gather runner.
100    ///
101    /// `dispatcher` drives sequential stages directly and serves as the fork
102    /// source for parallel workers.
103    pub fn new(
104        tools: Arc<ToolRegistry>,
105        dispatcher: Arc<dyn CommandDispatcher>,
106    ) -> Self {
107        Self { tools, sequential_dispatcher: dispatcher }
108    }
109
110    /// Execute a scatter/gather pipeline.
111    ///
112    /// The pipeline is split into three parts:
113    /// - pre_scatter: commands before scatter
114    /// - parallel: commands between scatter and gather
115    /// - post_gather: commands after gather
116    ///
117    /// Returns the final result after all stages complete.
118    #[tracing::instrument(level = "info", skip(self, pre_scatter, scatter_opts, parallel, gather_opts, post_gather, ctx), fields(item_count = tracing::field::Empty, parallelism = scatter_opts.limit))]
119    pub async fn run(
120        &self,
121        pre_scatter: &[Command],
122        scatter_opts: ScatterOptions,
123        parallel: &[Command],
124        gather_opts: GatherOptions,
125        post_gather: &[Command],
126        ctx: &mut ExecContext,
127    ) -> ExecResult {
128        let runner = PipelineRunner::new(self.tools.clone());
129
130        // Run pre-scatter commands to get input.
131        // Uses run_sequential to avoid async recursion (scatter → run → scatter).
132        let (text, data) = if pre_scatter.is_empty() {
133            // Use existing stdin — structured data, a String buffer, or a lazy
134            // `pipe_stdin` (a frontend-seeded process-stdin pipe). `take_stdin`
135            // alone would miss the pipe; `read_stdin_to_text` prefers it.
136            let data = ctx.take_stdin_data();
137            let text = match ctx.read_stdin_to_text().await {
138                Ok(s) => s.unwrap_or_default(),
139                Err(e) => return ExecResult::failure(2, format!("scatter: {e}")),
140            };
141            (text, data)
142        } else {
143            let result = runner.run_sequential(pre_scatter, ctx, &*self.sequential_dispatcher).await;
144            if !result.ok() {
145                return result;
146            }
147            (result.text_out().into_owned(), result.data)
148        };
149
150        // Extract items from structured data or text
151        let items = match extract_items(data.as_ref(), &text) {
152            Ok(items) => items,
153            Err(msg) => return ExecResult::failure(1, msg),
154        };
155        if items.is_empty() {
156            return ExecResult::success("");
157        }
158
159        tracing::Span::current().record("item_count", items.len());
160
161        // Run parallel stage
162        let results = self
163            .run_parallel(&items, &scatter_opts, parallel, ctx)
164            .await;
165
166        // Gather results
167        let GatherOutput {
168            text: gathered,
169            dropped_failures,
170        } = gather_results(&results, &gather_opts);
171
172        // The line format can't carry a failed worker as a row. Rather than
173        // silently omit it (data corruption — the caller sees fewer rows than
174        // items scattered), fail loud: a non-zero exit plus an err naming the
175        // failed items. Feeding the truncated set into post-gather would
176        // propagate the corruption, so we short-circuit before running it.
177        if !dropped_failures.is_empty() {
178            let err = format!(
179                "gather: {} task(s) failed and were omitted from line output: {} (use --json to capture per-task status)",
180                dropped_failures.len(),
181                dropped_failures.join(", ")
182            );
183            return ExecResult::from_output(1, gathered, err);
184        }
185
186        // Run post-gather commands if any
187        if post_gather.is_empty() {
188            ExecResult::success(gathered)
189        } else {
190            ctx.set_stdin(gathered);
191            runner.run_sequential(post_gather, ctx, &*self.sequential_dispatcher).await
192        }
193    }
194
195    /// Run the parallel stage for all items.
196    ///
197    /// Each worker gets its own forked dispatcher via
198    /// [`CommandDispatcher::fork`]. The fork snapshots per-session state
199    /// (scope, cwd, aliases, user tools) so workers can run concurrently
200    /// without racing. Forks are cheap (Scope is COW, plus a few Arc bumps),
201    /// and they unlock the full dispatch chain inside workers — user tools,
202    /// `.kai` scripts, and `$(...)` in args all work.
203    #[tracing::instrument(level = "debug", skip(self, items, opts, commands, base_ctx), fields(worker_count = items.len()))]
204    async fn run_parallel(
205        &self,
206        items: &[String],
207        opts: &ScatterOptions,
208        commands: &[Command],
209        base_ctx: &ExecContext,
210    ) -> Vec<ScatterResult> {
211        let semaphore = Arc::new(Semaphore::new(opts.limit));
212        let tools = self.tools.clone();
213        let var_name = opts.var_name.clone();
214
215        // Spawn parallel tasks
216        let mut handles = Vec::with_capacity(items.len());
217
218        for item in items.iter().cloned() {
219            let permit = semaphore.clone().acquire_owned().await;
220            let tools = tools.clone();
221            // Fork attached: the worker's cancel token is a child of the
222            // parent kernel's, so a parent cancel (request timeout, embedder
223            // Kernel::cancel) cascades into the worker and kills its
224            // external children via the wait_or_kill discipline.
225            let worker_dispatcher = self.sequential_dispatcher.fork_attached().await;
226            let commands = commands.to_vec();
227            let var_name = var_name.clone();
228            let base_scope = base_ctx.scope.clone();
229            let backend = base_ctx.backend.clone();
230            let cwd = base_ctx.cwd.clone();
231            let parent_token = base_ctx.cancel.clone();
232            let worker_token = parent_token.child_token();
233
234            // Per-worker timeout: spawn a delay task that cancels the worker's
235            // child token after `opts.timeout`. The cancel cascades into the
236            // worker's externals via the fork's cancel link. `timed_out_flag`
237            // distinguishes timeout from explicit parent cancellation when
238            // tagging ScatterResult.
239            let timed_out_flag = Arc::new(AtomicBool::new(false));
240            let timer_handle: Option<tokio::task::JoinHandle<()>> = opts.timeout.map(|d| {
241                let cancel = worker_token.clone();
242                let flag = timed_out_flag.clone();
243                tokio::spawn(async move {
244                    tokio::time::sleep(d).await;
245                    flag.store(true, Ordering::SeqCst);
246                    cancel.cancel();
247                })
248            });
249            let timed_out_check = timed_out_flag.clone();
250
251            let item_label = if item.len() > 64 {
252                format!("{}...", &item[..64])
253            } else {
254                item.clone()
255            };
256            let worker_span = tracing::debug_span!("scatter_worker", item = %item_label);
257            // Propagate the embedder's trace context across the spawn boundary so
258            // each worker's spans stay in the same trace. `.instrument` below
259            // provides the tracing parent; this provides the OTel parent.
260            let handle = tokio::spawn(crate::telemetry::bind_current_context(async move {
261                let _permit = permit; // Hold permit until done
262
263                // Create context for this worker
264                let mut scope = base_scope;
265                scope.set(&var_name, Value::String(item.clone()));
266
267                let mut ctx = ExecContext::with_backend_and_scope(backend, scope);
268                ctx.set_cwd(cwd);
269                ctx.cancel = worker_token;
270
271                // Run through PipelineRunner + dispatcher (full resolution chain).
272                // Uses run_sequential to avoid async recursion and infinite future size.
273                let runner = PipelineRunner::new(tools);
274                let result = runner.run_sequential(&commands, &mut ctx, &*worker_dispatcher).await;
275
276                // Worker finished — abort the timer if still pending so it
277                // doesn't fire a now-pointless cancel and idle resources.
278                if let Some(h) = timer_handle {
279                    h.abort();
280                }
281
282                let timed_out = timed_out_check.load(Ordering::SeqCst);
283                ScatterResult { item, result, timed_out }
284            }.instrument(worker_span)));
285
286            handles.push(handle);
287        }
288
289        // Collect results
290        let mut results = Vec::with_capacity(handles.len());
291        for handle in handles {
292            match handle.await {
293                Ok(result) => results.push(result),
294                Err(e) => {
295                    results.push(ScatterResult {
296                        item: String::new(),
297                        result: ExecResult::failure(1, format!("Task panicked: {}", e)),
298                        timed_out: false,
299                    });
300                }
301            }
302        }
303
304        results
305    }
306}
307
308/// Extract items from structured data or text.
309///
310/// Structured `.data` (a JSON array from split/seq/glob/find) wins and fans out
311/// element-by-element. Plain-text stdin is split on newlines only — one item per
312/// line — matching the for-loop `$(cmd)` contract (docs/plan-for-loop-newline-split.md):
313/// trailing newlines are trimmed once (no phantom tail item), each line's trailing
314/// `\r` is stripped, interior blank lines are preserved, and whitespace within a
315/// line is never split. Empty / newline-only input yields zero items.
316pub fn extract_items(data: Option<&Value>, text: &str) -> Result<Vec<String>, String> {
317    // 1. Structured data (JSON array from split/seq/glob/find) — use it
318    if let Some(Value::Json(serde_json::Value::Array(arr))) = data {
319        return Ok(arr.iter().map(|v| match v {
320            serde_json::Value::String(s) => s.clone(),
321            other => other.to_string(),
322        }).collect());
323    }
324    if let Some(Value::String(s)) = data {
325        return Ok(vec![s.clone()]);
326    }
327
328    // 2. Plain text — newline-split, mirroring kernel.rs for-loop $(cmd) semantics.
329    let trimmed = text.trim_end_matches(['\n', '\r']);
330    if trimmed.is_empty() {
331        return Ok(vec![]);
332    }
333    Ok(trimmed
334        .split('\n')
335        .map(|line| line.trim_end_matches('\r').to_string())
336        .collect())
337}
338
339/// Rendered gather output plus the names of any failed tasks that the
340/// line format could not represent as a row.
341struct GatherOutput {
342    text: String,
343    /// Items whose worker failed and were omitted from `text`. Only the
344    /// line format populates this — the JSON format carries every task as a
345    /// row with an explicit `"ok"` field, so nothing is dropped there.
346    dropped_failures: Vec<String>,
347}
348
349/// Gather results into output string.
350///
351/// The JSON format emits every task as a row (`"ok"` discriminates success
352/// from failure). The line format can only carry stdout, so it returns the
353/// successful rows in `text` and reports the failed items in
354/// `dropped_failures` — the caller (`run`) turns that into a loud non-zero
355/// exit rather than letting the failures vanish (see `docs/issues.md`).
356fn gather_results(results: &[ScatterResult], opts: &GatherOptions) -> GatherOutput {
357    let results_to_use = if opts.first > 0 && opts.first < results.len() {
358        &results[..opts.first]
359    } else {
360        results
361    };
362
363    if opts.format == "json" {
364        // Output as JSON array of objects
365        let json_results: Vec<serde_json::Value> = results_to_use
366            .iter()
367            .map(|r| {
368                serde_json::json!({
369                    "item": r.item,
370                    "ok": r.result.ok(),
371                    "code": r.result.code,
372                    "out": r.result.text_out().trim(),
373                    "err": r.result.err.trim(),
374                    "timed_out": r.timed_out,
375                })
376            })
377            .collect();
378
379        GatherOutput {
380            text: serde_json::to_string_pretty(&json_results).unwrap_or_default(),
381            dropped_failures: Vec::new(),
382        }
383    } else {
384        // Output as lines (stdout from each successful worker, separated by
385        // newlines). Failed workers can't be represented as a stdout row, so
386        // we collect their items and let `run` fail loud instead of dropping
387        // them silently.
388        let text = results_to_use
389            .iter()
390            .filter(|r| r.result.ok())
391            .map(|r| r.result.text_out())
392            .map(|t| t.trim().to_string())
393            .collect::<Vec<_>>()
394            .join("\n");
395        let dropped_failures = results_to_use
396            .iter()
397            .filter(|r| !r.result.ok())
398            .map(|r| r.item.clone())
399            .collect();
400        GatherOutput {
401            text,
402            dropped_failures,
403        }
404    }
405}
406
407/// Parse scatter options from tool args.
408pub fn parse_scatter_options(args: &crate::tools::ToolArgs) -> ScatterOptions {
409    let mut opts = ScatterOptions::default();
410
411    if let Some(Value::String(name)) = args.named.get("as") {
412        opts.var_name = name.clone();
413    }
414
415    if let Some(Value::Int(n)) = args.named.get("limit") {
416        let requested = *n;
417        let clamped = requested.clamp(1, SCATTER_LIMIT_MAX as i64);
418        if requested > SCATTER_LIMIT_MAX as i64 {
419            tracing::warn!(
420                target: "kaish::scatter",
421                requested = requested,
422                ceiling = SCATTER_LIMIT_MAX,
423                "scatter limit clamped to ceiling"
424            );
425        }
426        opts.limit = clamped as usize;
427    }
428
429    // --timeout DURATION: per-worker timeout. Accepts the same forms as the
430    // `timeout` builtin (30, 5s, 500ms, 2m, 1h). Invalid input is ignored
431    // with a warn so a typo doesn't silently disable cancellation.
432    if let Some(Value::String(s)) = args.named.get("timeout") {
433        match parse_duration(s) {
434            Some(d) => opts.timeout = Some(d),
435            None => tracing::warn!(
436                target: "kaish::scatter",
437                value = %s,
438                "scatter --timeout: invalid duration (try: 30, 5s, 500ms, 2m, 1h)"
439            ),
440        }
441    } else if let Some(Value::Int(n)) = args.named.get("timeout") {
442        if *n >= 0 {
443            opts.timeout = Some(Duration::from_secs(*n as u64));
444        }
445    }
446
447    opts
448}
449
450/// Upper bound on the concurrency `scatter --limit N` accepts. Users who
451/// ask for more get a `tracing::warn` and are clamped to this value —
452/// silent clamping would violate the "no silent fallbacks" rule.
453pub const SCATTER_LIMIT_MAX: usize = 10_000;
454
455/// Parse gather options from tool args.
456pub fn parse_gather_options(args: &crate::tools::ToolArgs) -> GatherOptions {
457    let mut opts = GatherOptions::default();
458
459    if args.has_flag("progress") {
460        opts.progress = true;
461    }
462
463    if let Some(Value::Int(n)) = args.named.get("first") {
464        opts.first = (*n).max(0) as usize;
465    }
466
467    if let Some(Value::String(fmt)) = args.named.get("format") {
468        opts.format = fmt.clone();
469    }
470
471    opts
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    #[test]
479    fn test_extract_items_structured_json_array() {
480        let data = Value::Json(serde_json::json!(["a", "b", "c"]));
481        let items = extract_items(Some(&data), "").unwrap();
482        assert_eq!(items, vec!["a", "b", "c"]);
483    }
484
485    #[test]
486    fn test_extract_items_structured_mixed_types() {
487        let data = Value::Json(serde_json::json!([1, "two", true]));
488        let items = extract_items(Some(&data), "").unwrap();
489        assert_eq!(items, vec!["1", "two", "true"]);
490    }
491
492    #[test]
493    fn test_extract_items_structured_string() {
494        let data = Value::String("single".into());
495        let items = extract_items(Some(&data), "").unwrap();
496        assert_eq!(items, vec!["single"]);
497    }
498
499    #[test]
500    fn test_extract_items_single_line_text() {
501        let items = extract_items(None, "hello").unwrap();
502        assert_eq!(items, vec!["hello"]);
503    }
504
505    #[test]
506    fn test_extract_items_empty() {
507        let items = extract_items(None, "").unwrap();
508        assert!(items.is_empty());
509    }
510
511    #[test]
512    fn test_extract_items_multiline_fans_out_per_line() {
513        // Plain-text stdin splits on newlines, matching for-loop $(cmd)
514        // semantics (docs/plan-for-loop-newline-split.md) — one worker per line.
515        let items = extract_items(None, "one\ntwo\nthree").unwrap();
516        assert_eq!(items, vec!["one", "two", "three"]);
517    }
518
519    #[test]
520    fn test_extract_items_trailing_newline_no_phantom_item() {
521        // Trailing newline is trimmed once before splitting — no empty tail item.
522        let items = extract_items(None, "one\ntwo\n").unwrap();
523        assert_eq!(items, vec!["one", "two"]);
524    }
525
526    #[test]
527    fn test_extract_items_crlf_per_line() {
528        // Each line's trailing \r is stripped (CRLF input).
529        let items = extract_items(None, "one\r\ntwo\r\n").unwrap();
530        assert_eq!(items, vec!["one", "two"]);
531    }
532
533    #[test]
534    fn test_extract_items_interior_blank_line_preserved() {
535        // Interior empty lines are preserved (matches for-loop split('\n')).
536        let items = extract_items(None, "a\n\nb").unwrap();
537        assert_eq!(items, vec!["a", "", "b"]);
538    }
539
540    #[test]
541    fn test_extract_items_whitespace_within_line_not_split() {
542        // Only newlines split; spaces within a line stay in the item.
543        let items = extract_items(None, "a b\nc d").unwrap();
544        assert_eq!(items, vec!["a b", "c d"]);
545    }
546
547    #[test]
548    fn test_extract_items_only_newlines_is_empty() {
549        let items = extract_items(None, "\n\n").unwrap();
550        assert!(items.is_empty());
551    }
552
553    #[test]
554    fn test_extract_items_structured_overrides_text() {
555        // Structured data takes priority over text
556        let data = Value::Json(serde_json::json!(["x", "y"]));
557        let items = extract_items(Some(&data), "ignored\ntext").unwrap();
558        assert_eq!(items, vec!["x", "y"]);
559    }
560
561    #[test]
562    fn test_gather_results_lines() {
563        let results = vec![
564            ScatterResult {
565                item: "a".to_string(),
566                result: ExecResult::success("result_a"),
567                timed_out: false,
568            },
569            ScatterResult {
570                item: "b".to_string(),
571                result: ExecResult::success("result_b"),
572                timed_out: false,
573            },
574        ];
575
576        let opts = GatherOptions::default();
577        let output = gather_results(&results, &opts);
578        assert_eq!(output.text, "result_a\nresult_b");
579        assert!(output.dropped_failures.is_empty());
580    }
581
582    #[test]
583    fn test_gather_results_lines_reports_dropped_failures() {
584        // A failed worker must not vanish from line output: it is reported in
585        // `dropped_failures` so the caller can fail loud (docs/issues.md).
586        let results = vec![
587            ScatterResult {
588                item: "a".to_string(),
589                result: ExecResult::success("result_a"),
590                timed_out: false,
591            },
592            ScatterResult {
593                item: "b".to_string(),
594                result: ExecResult::failure(1, "boom"),
595                timed_out: false,
596            },
597        ];
598
599        let opts = GatherOptions::default();
600        let output = gather_results(&results, &opts);
601        // Successful rows still render; the failure is reported, not dropped.
602        assert_eq!(output.text, "result_a");
603        assert_eq!(output.dropped_failures, vec!["b".to_string()]);
604    }
605
606    #[test]
607    fn test_gather_results_json_keeps_failures_as_rows() {
608        // JSON carries failures as rows (ok: false), so it drops nothing.
609        let results = vec![ScatterResult {
610            item: "b".to_string(),
611            result: ExecResult::failure(2, "boom"),
612            timed_out: false,
613        }];
614        let opts = GatherOptions {
615            format: "json".to_string(),
616            ..Default::default()
617        };
618        let output = gather_results(&results, &opts);
619        assert!(output.dropped_failures.is_empty());
620        assert!(output.text.contains("\"ok\": false"));
621        assert!(output.text.contains("\"code\": 2"));
622    }
623
624    #[test]
625    fn test_gather_results_json() {
626        let results = vec![ScatterResult {
627            item: "test".to_string(),
628            result: ExecResult::success("output"),
629            timed_out: false,
630        }];
631
632        let opts = GatherOptions {
633            format: "json".to_string(),
634            ..Default::default()
635        };
636        let output = gather_results(&results, &opts);
637        assert!(output.text.contains("\"item\": \"test\""));
638        assert!(output.text.contains("\"ok\": true"));
639    }
640
641    #[test]
642    fn test_gather_results_first_n() {
643        let results = vec![
644            ScatterResult {
645                item: "a".to_string(),
646                result: ExecResult::success("1"),
647                timed_out: false,
648            },
649            ScatterResult {
650                item: "b".to_string(),
651                result: ExecResult::success("2"),
652                timed_out: false,
653            },
654            ScatterResult {
655                item: "c".to_string(),
656                result: ExecResult::success("3"),
657                timed_out: false,
658            },
659        ];
660
661        let opts = GatherOptions {
662            first: 2,
663            ..Default::default()
664        };
665        let output = gather_results(&results, &opts);
666        assert_eq!(output.text, "1\n2");
667    }
668
669    #[test]
670    fn test_parse_scatter_options() {
671        use crate::tools::ToolArgs;
672
673        let mut args = ToolArgs::new();
674        args.named.insert("as".to_string(), Value::String("URL".to_string()));
675        args.named.insert("limit".to_string(), Value::Int(4));
676
677        let opts = parse_scatter_options(&args);
678        assert_eq!(opts.var_name, "URL");
679        assert_eq!(opts.limit, 4);
680    }
681
682    #[test]
683    fn test_parse_gather_options() {
684        use crate::tools::ToolArgs;
685
686        let mut args = ToolArgs::new();
687        args.named.insert("first".to_string(), Value::Int(5));
688        args.named.insert("format".to_string(), Value::String("json".to_string()));
689
690        let opts = parse_gather_options(&args);
691        assert_eq!(opts.first, 5);
692        assert_eq!(opts.format, "json");
693    }
694
695    #[test]
696    fn scatter_limit_clamps_to_ceiling() {
697        use crate::tools::ToolArgs;
698
699        let mut args = ToolArgs::new();
700        args.named.insert("limit".to_string(), Value::Int(999_999));
701        let opts = parse_scatter_options(&args);
702        assert_eq!(opts.limit, SCATTER_LIMIT_MAX);
703    }
704
705    #[test]
706    fn scatter_limit_raises_zero_to_one() {
707        use crate::tools::ToolArgs;
708
709        let mut args = ToolArgs::new();
710        args.named.insert("limit".to_string(), Value::Int(0));
711        let opts = parse_scatter_options(&args);
712        assert_eq!(opts.limit, 1);
713    }
714
715    #[test]
716    fn scatter_limit_raises_negative_to_one() {
717        use crate::tools::ToolArgs;
718
719        let mut args = ToolArgs::new();
720        args.named.insert("limit".to_string(), Value::Int(-42));
721        let opts = parse_scatter_options(&args);
722        assert_eq!(opts.limit, 1);
723    }
724
725    #[test]
726    fn scatter_limit_preserves_valid_values() {
727        use crate::tools::ToolArgs;
728
729        let mut args = ToolArgs::new();
730        args.named.insert("limit".to_string(), Value::Int(500));
731        let opts = parse_scatter_options(&args);
732        assert_eq!(opts.limit, 500);
733    }
734}