Skip to main content

bashkit_scripted_tool/
tool_def.rs

1// ToolDef, ToolArgs, ToolImpl — reusable tool primitives.
2//
3// These types live here (not in scripted_tool/) so that both Bash and
4// ScriptedTool can import them without circular dependencies.
5//
6// Dependency direction:  builtins → tool_def → {lib.rs, scripted_tool, tool.rs}
7
8use async_trait::async_trait;
9use bashkit::ExecResult;
10use bashkit::Result;
11use bashkit::{Builtin, BuiltinContext as Context};
12use std::future::Future;
13use std::pin::Pin;
14use std::sync::Arc;
15
16// ============================================================================
17// ToolDef — OpenAPI-style tool definition (metadata only)
18// ============================================================================
19
20/// OpenAPI-style tool definition: name, description, input schema.
21///
22/// Describes a sub-tool registered with a `ScriptedToolBuilder` or usable
23/// standalone. The `input_schema` is optional JSON Schema for documentation /
24/// LLM prompts and for type coercion of `--key value` flags.
25#[derive(Clone)]
26pub struct ToolDef {
27    /// Command name used as bash builtin (e.g. `"get_user"`).
28    pub name: String,
29    /// Human-readable description for LLM consumption.
30    pub description: String,
31    /// JSON Schema describing accepted arguments. Empty object if unspecified.
32    pub input_schema: serde_json::Value,
33    /// Categorical tags for discovery (e.g. `["admin", "billing"]`).
34    pub tags: Vec<String>,
35    /// Grouping category for discovery (e.g. `"payments"`).
36    pub category: Option<String>,
37}
38
39impl ToolDef {
40    /// Create a tool definition with name and description.
41    pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
42        Self {
43            name: name.into(),
44            description: description.into(),
45            input_schema: serde_json::Value::Object(Default::default()),
46            tags: Vec::new(),
47            category: None,
48        }
49    }
50
51    /// Attach a JSON Schema for the tool's input parameters.
52    pub fn with_schema(mut self, schema: serde_json::Value) -> Self {
53        self.input_schema = schema;
54        self
55    }
56
57    /// Add categorical tags for discovery filtering.
58    pub fn with_tags(mut self, tags: &[&str]) -> Self {
59        self.tags = tags.iter().map(|s| s.to_string()).collect();
60        self
61    }
62
63    /// Set the grouping category for discovery.
64    pub fn with_category(mut self, category: &str) -> Self {
65        self.category = Some(category.to_string());
66        self
67    }
68}
69
70// ============================================================================
71// ToolArgs — parsed arguments passed to exec functions
72// ============================================================================
73
74/// Parsed arguments passed to a tool exec function.
75///
76/// `params` is a JSON object built from `--key value` flags, with values
77/// type-coerced per the `ToolDef`'s `input_schema`.
78/// `stdin` carries pipeline input from a prior command, if any.
79pub struct ToolArgs {
80    /// Parsed parameters as a JSON object. Keys from `--key value` flags.
81    pub params: serde_json::Value,
82    /// Pipeline input from a prior command (e.g. `echo data | tool`).
83    pub stdin: Option<String>,
84    context: ToolArgsContext,
85}
86
87#[derive(Default)]
88struct ToolArgsContext {
89    capability: Option<bashkit::ExecutionCapability<ToolArgsContextData>>,
90}
91
92#[derive(Clone)]
93pub(crate) struct ToolArgsContextData {
94    tenant_id: Option<String>,
95    surface: Option<crate::tool_registry::ToolCallSurface>,
96}
97
98impl ToolArgsContextData {
99    pub(crate) fn new(
100        tenant_id: Option<String>,
101        surface: crate::tool_registry::ToolCallSurface,
102    ) -> Self {
103        Self {
104            tenant_id,
105            surface: Some(surface),
106        }
107    }
108}
109
110impl ToolArgs {
111    /// Construct arguments outside a registry request.
112    pub fn new(params: serde_json::Value, stdin: Option<String>) -> Self {
113        Self {
114            params,
115            stdin,
116            context: ToolArgsContext::default(),
117        }
118    }
119
120    pub(crate) fn with_context(
121        params: serde_json::Value,
122        stdin: Option<String>,
123        capability: bashkit::ExecutionCapability<ToolArgsContextData>,
124    ) -> Self {
125        Self {
126            params,
127            stdin,
128            context: ToolArgsContext {
129                capability: Some(capability),
130            },
131        }
132    }
133
134    /// Request tenant while the originating execution remains active.
135    pub fn tenant_id(
136        &self,
137    ) -> std::result::Result<Option<String>, bashkit::ExecutionCapabilityError> {
138        self.context
139            .capability
140            .as_ref()
141            .map(|capability| capability.try_with(|context| context.tenant_id.clone()))
142            .transpose()
143            .map(Option::flatten)
144    }
145
146    /// Runtime surface while the originating execution remains active.
147    pub fn surface(
148        &self,
149    ) -> std::result::Result<
150        Option<crate::tool_registry::ToolCallSurface>,
151        bashkit::ExecutionCapabilityError,
152    > {
153        self.context
154            .capability
155            .as_ref()
156            .map(|capability| capability.try_with(|context| context.surface))
157            .transpose()
158            .map(Option::flatten)
159    }
160    /// Get a string parameter by name.
161    pub fn param_str(&self, key: &str) -> Option<&str> {
162        self.params.get(key).and_then(|v| v.as_str())
163    }
164
165    /// Get an integer parameter by name.
166    pub fn param_i64(&self, key: &str) -> Option<i64> {
167        self.params.get(key).and_then(|v| v.as_i64())
168    }
169
170    /// Get a float parameter by name.
171    pub fn param_f64(&self, key: &str) -> Option<f64> {
172        self.params.get(key).and_then(|v| v.as_f64())
173    }
174
175    /// Get a boolean parameter by name.
176    pub fn param_bool(&self, key: &str) -> Option<bool> {
177        self.params.get(key).and_then(|v| v.as_bool())
178    }
179}
180
181// ============================================================================
182// Exec types — sync and async execution functions
183// ============================================================================
184
185/// Synchronous execution function for a tool.
186///
187/// Receives parsed [`ToolArgs`] with typed parameters and optional stdin.
188/// Return `Ok(stdout)` on success or `Err(message)` on failure.
189pub type SyncToolExec = Arc<dyn Fn(&ToolArgs) -> std::result::Result<String, String> + Send + Sync>;
190
191/// Asynchronous execution function for a tool.
192///
193/// Same contract as [`SyncToolExec`] but returns a `Future`, allowing
194/// non-blocking I/O. Takes owned [`ToolArgs`] because the future may
195/// outlive the borrow.
196pub type AsyncToolExec = Arc<
197    dyn Fn(ToolArgs) -> Pin<Box<dyn Future<Output = std::result::Result<String, String>> + Send>>
198        + Send
199        + Sync,
200>;
201
202// Keep old names as aliases for backward compatibility.
203/// Alias for [`SyncToolExec`] (backward compatibility).
204pub type ToolCallback = SyncToolExec;
205/// Alias for [`AsyncToolExec`] (backward compatibility).
206pub type AsyncToolCallback = AsyncToolExec;
207
208// ============================================================================
209// ToolImpl — complete tool: metadata + execution
210// ============================================================================
211
212/// Complete tool: definition + sync/async exec functions.
213///
214/// Implements [`Builtin`] so it can be registered directly in a Bash
215/// interpreter or used inside a `ScriptedTool`.
216///
217/// # Example
218///
219/// ```rust
220/// use bashkit_scripted_tool::{ToolDef, ToolImpl};
221///
222/// let tool = ToolImpl::new(
223///     ToolDef::new("greet", "Greet a user")
224///         .with_schema(serde_json::json!({
225///             "type": "object",
226///             "properties": { "name": {"type": "string"} }
227///         })),
228/// )
229/// .with_exec_sync(|args| {
230///     let name = args.param_str("name").unwrap_or("world");
231///     Ok(format!("hello {name}\n"))
232/// });
233/// ```
234#[derive(Clone)]
235pub struct ToolImpl {
236    /// Tool metadata (name, description, schema, tags).
237    pub def: ToolDef,
238    /// Async exec (preferred when running in async context).
239    pub exec: Option<AsyncToolExec>,
240    /// Sync exec (preferred when running in sync context).
241    pub exec_sync: Option<SyncToolExec>,
242    /// Whether callback error strings are sanitized before script-visible stderr.
243    pub sanitize_errors: bool,
244}
245
246impl ToolImpl {
247    /// Create a `ToolImpl` from a [`ToolDef`] with no exec functions.
248    pub fn new(def: ToolDef) -> Self {
249        Self {
250            def,
251            exec: None,
252            exec_sync: None,
253            sanitize_errors: true,
254        }
255    }
256
257    /// Set the async exec function.
258    pub fn with_exec<F, Fut>(mut self, f: F) -> Self
259    where
260        F: Fn(ToolArgs) -> Fut + Send + Sync + 'static,
261        Fut: Future<Output = std::result::Result<String, String>> + Send + 'static,
262    {
263        self.exec = Some(Arc::new(move |args| Box::pin(f(args))));
264        self
265    }
266
267    /// Set the sync exec function.
268    pub fn with_exec_sync(
269        mut self,
270        f: impl Fn(&ToolArgs) -> std::result::Result<String, String> + Send + Sync + 'static,
271    ) -> Self {
272        self.exec_sync = Some(Arc::new(f));
273        self
274    }
275
276    /// Control whether callback errors are sanitized before reaching stderr.
277    ///
278    /// Default: `true`, replacing callback `Err(String)` with
279    /// `"<tool>: callback failed\n"` to avoid exposing host-side secrets,
280    /// paths, connection strings, or stack traces to untrusted scripts.
281    /// Set to `false` only when callers are trusted and raw diagnostics are safe.
282    // THREAT[TM-INF-030]: ToolImpl may be registered directly as a Builtin, so
283    // it must match ScriptedTool's safe default instead of leaking raw callback errors.
284    pub fn sanitize_errors(mut self, sanitize: bool) -> Self {
285        self.sanitize_errors = sanitize;
286        self
287    }
288
289    fn callback_failed_message(&self) -> String {
290        format!("{}: callback failed\n", self.def.name)
291    }
292}
293
294#[async_trait]
295impl Builtin for ToolImpl {
296    async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
297        let params = parse_flags(ctx.args, &self.def.input_schema)
298            .map_err(|e| bashkit::Error::Execution(format!("{}: {e}", self.def.name)))?;
299        let context = ToolArgsContextData::new(None, crate::tool_registry::ToolCallSurface::Shell);
300        let stdin = ctx.stdin.map(String::from);
301        let tool_args = match ctx.execution_capability(context) {
302            Some(capability) => ToolArgs::with_context(params, stdin, capability),
303            // Direct Builtin unit tests have no request lifecycle. Production
304            // shell dispatch always supplies an execution scope.
305            None => ToolArgs::new(params, stdin),
306        };
307
308        // Prefer async, fall back to sync.
309        let result = if let Some(cb) = &self.exec {
310            ctx.run_budgeted((cb)(tool_args)).await?
311        } else if let Some(cb) = &self.exec_sync {
312            if let Some(budget) = ctx.execution_budget() {
313                budget
314                    .try_with(|budget| budget.check())
315                    .map_err(|_| bashkit::Error::Cancelled)??;
316            }
317            let result = (cb)(&tool_args);
318            if let Some(budget) = ctx.execution_budget() {
319                budget
320                    .try_with(|budget| budget.check())
321                    .map_err(|_| bashkit::Error::Cancelled)??;
322            }
323            result
324        } else {
325            return Err(bashkit::Error::Execution(format!(
326                "{}: no exec defined",
327                self.def.name
328            )));
329        };
330
331        match result {
332            Ok(stdout) => Ok(ExecResult::ok(stdout)),
333            Err(msg) if self.sanitize_errors => {
334                #[cfg(not(feature = "tracing"))]
335                let _ = &msg;
336                #[cfg(feature = "tracing")]
337                tracing::debug!(
338                    tool = %self.def.name,
339                    error = %msg,
340                    "tool callback error (sanitized)"
341                );
342                Ok(ExecResult::err(self.callback_failed_message(), 1))
343            }
344            Err(msg) => Ok(ExecResult::err(msg, 1)),
345        }
346    }
347}
348
349// ============================================================================
350// Flag parser — `--key value` / `--key=value` → JSON object
351// ============================================================================
352
353/// Parse `--key value` and `--key=value` flags into a JSON object.
354/// Types are coerced according to the schema's property definitions.
355/// Unknown flags (not in schema) are kept as strings, unless the schema sets
356/// `"additionalProperties": false`, in which case an unknown flag is an error.
357/// That is the same rule `ToolRegistry` already applies to structured calls, so
358/// one schema describes one command across the shell and registry surfaces.
359/// Bare `--flag` without a value is treated as `true` if the schema says boolean,
360/// otherwise as `true` when the next arg also starts with `--` or is absent.
361///
362/// For aggregate flags (`type: "object"` or `type: "array"`), `key=value`
363/// pair tokens are accepted alongside JSON: a sequence of pair tokens
364/// after `--flag` is collected into one object; repeated invocations of an
365/// array-of-object flag append one object per group. Arrays of scalars
366/// accept comma-split values (`--tags a,b,c`) and repeated invocations.
367/// Error for a flag the schema does not name, listing what it does name.
368fn unknown_flag_error(
369    key: &str,
370    properties: &serde_json::Map<String, serde_json::Value>,
371) -> String {
372    if properties.is_empty() {
373        return format!("unknown flag: --{key} (this command takes no flags)");
374    }
375    let mut known: Vec<&str> = properties.keys().map(String::as_str).collect();
376    known.sort_unstable();
377    let known = known
378        .iter()
379        .map(|name| format!("--{name}"))
380        .collect::<Vec<_>>()
381        .join(", ");
382    format!("unknown flag: --{key} (expected one of: {known})")
383}
384
385pub(crate) fn parse_flags(
386    raw_args: &[String],
387    schema: &serde_json::Value,
388) -> std::result::Result<serde_json::Value, String> {
389    let mut budget = FlagParseBudget::default();
390    parse_flags_with_budget(raw_args, schema, &mut budget)
391}
392
393fn parse_flags_with_budget(
394    raw_args: &[String],
395    schema: &serde_json::Value,
396    budget: &mut FlagParseBudget,
397) -> std::result::Result<serde_json::Value, String> {
398    let properties = schema
399        .get("properties")
400        .and_then(|p| p.as_object())
401        .cloned()
402        .unwrap_or_default();
403    // A schema that closes itself with `additionalProperties: false` means a
404    // flag it does not name is a mistake, not an extra string property. Without
405    // this a typo (`--limti 10`) parses, dispatches, and is silently dropped —
406    // indistinguishable from omitting the flag.
407    let reject_unknown =
408        schema.get("additionalProperties") == Some(&serde_json::Value::Bool(false));
409
410    let mut result = serde_json::Map::new();
411    let mut i = 0;
412
413    while i < raw_args.len() {
414        let arg = &raw_args[i];
415
416        let Some(flag) = arg.strip_prefix("--") else {
417            return Err(format!("expected --flag, got: {arg}"));
418        };
419
420        // --key=value
421        if let Some((key, raw_value)) = flag.split_once('=') {
422            if reject_unknown && !properties.contains_key(key) {
423                return Err(unknown_flag_error(key, &properties));
424            }
425            budget.add_bytes(raw_value.len(), key)?;
426            let value = coerce_value(raw_value, properties.get(key), schema);
427            result.insert(key.to_string(), value);
428            i += 1;
429            continue;
430        }
431
432        let key = flag.to_string();
433        if reject_unknown && !properties.contains_key(&key) {
434            return Err(unknown_flag_error(&key, &properties));
435        }
436        let prop_schema = properties.get(&key).cloned();
437        let effective = prop_schema
438            .as_ref()
439            .map(|s| resolve_effective_type(s, schema, 0))
440            .unwrap_or(EffectiveType::Unknown);
441
442        i += 1;
443
444        match effective {
445            EffectiveType::Boolean => {
446                result.insert(key, serde_json::Value::Bool(true));
447            }
448            EffectiveType::Array => {
449                let items_schema = prop_schema.as_ref().and_then(|s| s.get("items")).cloned();
450                let items_effective = items_schema
451                    .as_ref()
452                    .map(|s| resolve_effective_type(s, schema, 0))
453                    .unwrap_or(EffectiveType::Unknown);
454
455                match consume_array_value(
456                    raw_args,
457                    &mut i,
458                    items_schema.as_ref(),
459                    items_effective,
460                    schema,
461                    &key,
462                    budget,
463                )? {
464                    ArrayInput::Items(items) => {
465                        let entry = result
466                            .entry(key.clone())
467                            .or_insert_with(|| serde_json::Value::Array(Vec::new()));
468                        if let serde_json::Value::Array(arr) = entry {
469                            ensure_array_item_limit(&key, arr.len(), items.len())?;
470                            arr.extend(items);
471                        } else {
472                            ensure_array_item_limit(&key, 0, items.len())?;
473                            *entry = serde_json::Value::Array(items);
474                        }
475                    }
476                    ArrayInput::Raw(value) => {
477                        // Invalid JSON — preserve as raw string so serde
478                        // produces the real type-mismatch error downstream.
479                        result.insert(key, value);
480                    }
481                }
482            }
483            EffectiveType::Object => {
484                let value = consume_object_value(
485                    raw_args,
486                    &mut i,
487                    prop_schema.as_ref(),
488                    schema,
489                    &key,
490                    budget,
491                )?;
492                result.insert(key, value);
493            }
494            _ => {
495                if i < raw_args.len() && !raw_args[i].starts_with("--") {
496                    let raw_value = &raw_args[i];
497                    budget.add_bytes(raw_value.len(), &key)?;
498                    let value = coerce_value(raw_value, prop_schema.as_ref(), schema);
499                    result.insert(key, value);
500                    i += 1;
501                } else {
502                    result.insert(key, serde_json::Value::Bool(true));
503                }
504            }
505        }
506    }
507
508    Ok(serde_json::Value::Object(result))
509}
510
511/// Walk a schema to extract object property definitions, following `$ref`
512/// and merging properties across `oneOf`/`anyOf`/`allOf` branches.
513fn resolve_object_properties(
514    schema: &serde_json::Value,
515    root_schema: &serde_json::Value,
516    depth: usize,
517) -> serde_json::Map<String, serde_json::Value> {
518    if depth > MAX_REF_DEPTH {
519        return Default::default();
520    }
521    if let Some(ref_str) = schema.get("$ref").and_then(|r| r.as_str()) {
522        if let Some(target) = resolve_ref(ref_str, root_schema) {
523            return resolve_object_properties(target, root_schema, depth + 1);
524        }
525        return Default::default();
526    }
527    if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) {
528        return props.clone();
529    }
530    let mut merged = serde_json::Map::new();
531    for key in ["oneOf", "anyOf", "allOf"] {
532        if let Some(branches) = schema.get(key).and_then(|v| v.as_array()) {
533            for branch in branches {
534                let props = resolve_object_properties(branch, root_schema, depth + 1);
535                for (k, v) in props {
536                    merged.entry(k).or_insert(v);
537                }
538            }
539        }
540    }
541    merged
542}
543
544/// Collect `key=value` tokens into an object until the next `--flag` or end of args.
545fn collect_object_from_pairs(
546    args: &[String],
547    i: &mut usize,
548    object_schema: Option<&serde_json::Value>,
549    root_schema: &serde_json::Value,
550    flag_name: &str,
551    budget: &mut FlagParseBudget,
552) -> std::result::Result<serde_json::Map<String, serde_json::Value>, String> {
553    let mut obj = serde_json::Map::new();
554    let inner_props = object_schema
555        .map(|s| resolve_object_properties(s, root_schema, 0))
556        .unwrap_or_default();
557
558    while *i < args.len() {
559        let arg = &args[*i];
560        if arg.starts_with("--") {
561            break;
562        }
563        let Some((k, v)) = arg.split_once('=') else {
564            return Err(format!(
565                "--{flag_name}: expected --flag or key=value, got '{arg}'"
566            ));
567        };
568
569        if !inner_props.is_empty() && !inner_props.contains_key(k) {
570            let mut valid: Vec<&str> = inner_props.keys().map(|s| s.as_str()).collect();
571            valid.sort();
572            return Err(format!(
573                "--{flag_name}: unknown key '{k}'; valid keys: {}",
574                valid.join(", ")
575            ));
576        }
577
578        budget.add_bytes(k.len().saturating_add(v.len()), flag_name)?;
579        let nested_schema = inner_props.get(k);
580        let value = coerce_value(v, nested_schema, root_schema);
581        obj.insert(k.to_string(), value);
582        *i += 1;
583    }
584    Ok(obj)
585}
586
587/// Result of consuming the value(s) after an array-typed `--flag`.
588enum ArrayInput {
589    /// Items to append to the accumulated array for this flag.
590    Items(Vec<serde_json::Value>),
591    /// Raw fallback (typically when JSON parse failed) — overrides the
592    /// array entry with this value so serde surfaces the type error.
593    Raw(serde_json::Value),
594}
595
596/// Consume the value(s) following an array-typed `--flag`.
597/// Accepts: JSON array, JSON object (single-element append), `key=value` pair
598/// group (single-element append for arrays of objects), or comma-split scalars.
599fn consume_array_value(
600    args: &[String],
601    i: &mut usize,
602    items_schema: Option<&serde_json::Value>,
603    items_effective: EffectiveType,
604    root_schema: &serde_json::Value,
605    flag_name: &str,
606    budget: &mut FlagParseBudget,
607) -> std::result::Result<ArrayInput, String> {
608    if *i >= args.len() || args[*i].starts_with("--") {
609        return Err(format!("--{flag_name}: missing value"));
610    }
611    let next = &args[*i];
612    let trimmed = next.trim_start();
613
614    if trimmed.starts_with('[') {
615        if let Some(serde_json::Value::Array(arr)) = parse_aggregate_json_value(next) {
616            ensure_array_item_limit(flag_name, 0, arr.len())?;
617            budget.add_bytes(next.len(), flag_name)?;
618            *i += 1;
619            return Ok(ArrayInput::Items(arr));
620        }
621        *i += 1;
622        return Ok(ArrayInput::Raw(serde_json::Value::String(next.clone())));
623    }
624
625    if items_effective == EffectiveType::Object {
626        if trimmed.starts_with('{') {
627            if let Some(parsed) = parse_aggregate_json_value(next) {
628                budget.add_bytes(next.len(), flag_name)?;
629                *i += 1;
630                return Ok(ArrayInput::Items(vec![parsed]));
631            }
632            *i += 1;
633            return Ok(ArrayInput::Raw(serde_json::Value::String(next.clone())));
634        }
635        if next.contains('=') {
636            let obj =
637                collect_object_from_pairs(args, i, items_schema, root_schema, flag_name, budget)?;
638            return Ok(ArrayInput::Items(vec![serde_json::Value::Object(obj)]));
639        }
640        return Err(format!(
641            "--{flag_name}: expected JSON or key=value pairs, got '{next}'"
642        ));
643    }
644
645    // Scalar items: comma-split.
646    let item_count = next.split(',').count();
647    ensure_array_item_limit(flag_name, 0, item_count)?;
648    budget.add_bytes(next.len(), flag_name)?;
649    let mut out = Vec::with_capacity(item_count);
650    for part in next.split(',') {
651        out.push(coerce_value(part, items_schema, root_schema));
652    }
653    *i += 1;
654    Ok(ArrayInput::Items(out))
655}
656
657/// Consume the value(s) following an object-typed `--flag`.
658/// Accepts: JSON object/array literal, or a `key=value` pair group.
659fn consume_object_value(
660    args: &[String],
661    i: &mut usize,
662    prop_schema: Option<&serde_json::Value>,
663    root_schema: &serde_json::Value,
664    flag_name: &str,
665    budget: &mut FlagParseBudget,
666) -> std::result::Result<serde_json::Value, String> {
667    if *i >= args.len() || args[*i].starts_with("--") {
668        return Ok(serde_json::Value::Bool(true));
669    }
670    let next = &args[*i];
671    let trimmed = next.trim_start();
672
673    if trimmed.starts_with('{') || trimmed.starts_with('[') {
674        budget.add_bytes(next.len(), flag_name)?;
675        let value = coerce_value(next, prop_schema, root_schema);
676        *i += 1;
677        return Ok(value);
678    }
679
680    if next.contains('=') {
681        let obj = collect_object_from_pairs(args, i, prop_schema, root_schema, flag_name, budget)?;
682        return Ok(serde_json::Value::Object(obj));
683    }
684
685    budget.add_bytes(next.len(), flag_name)?;
686    let value = coerce_value(next, prop_schema, root_schema);
687    *i += 1;
688    Ok(value)
689}
690
691/// Effective type of a property schema after resolving `$ref`,
692/// `oneOf`/`anyOf`/`allOf` branches, nullable arrays
693/// (`type: ["array","null"]`), and implicit signals
694/// (`items` ⇒ array, `properties` ⇒ object).
695#[derive(PartialEq, Clone, Copy, Debug)]
696enum EffectiveType {
697    String,
698    Integer,
699    Number,
700    Boolean,
701    Array,
702    Object,
703    Unknown,
704}
705
706const MAX_REF_DEPTH: usize = 16;
707const MAX_AGGREGATE_FLAG_JSON_BYTES: usize = 64 * 1024;
708const MAX_PARSED_FLAG_BYTES: usize = 64 * 1024;
709const MAX_ARRAY_FLAG_ITEMS: usize = 4096;
710
711#[derive(Default)]
712struct FlagParseBudget {
713    bytes: usize,
714}
715
716impl FlagParseBudget {
717    fn add_bytes(&mut self, bytes: usize, flag_name: &str) -> std::result::Result<(), String> {
718        self.bytes = self.bytes.saturating_add(bytes);
719        if self.bytes > MAX_PARSED_FLAG_BYTES {
720            return Err(format!(
721                "--{flag_name}: parsed flag data exceeds {MAX_PARSED_FLAG_BYTES} bytes"
722            ));
723        }
724        Ok(())
725    }
726}
727
728fn ensure_array_item_limit(
729    flag_name: &str,
730    existing: usize,
731    adding: usize,
732) -> std::result::Result<(), String> {
733    let total = existing.saturating_add(adding);
734    if total > MAX_ARRAY_FLAG_ITEMS {
735        return Err(format!(
736            "--{flag_name}: array flag has {total} items; maximum is {MAX_ARRAY_FLAG_ITEMS}"
737        ));
738    }
739    Ok(())
740}
741
742fn type_str_to_effective(s: &str) -> EffectiveType {
743    match s {
744        "string" => EffectiveType::String,
745        "integer" => EffectiveType::Integer,
746        "number" => EffectiveType::Number,
747        "boolean" => EffectiveType::Boolean,
748        "array" => EffectiveType::Array,
749        "object" => EffectiveType::Object,
750        _ => EffectiveType::Unknown,
751    }
752}
753
754fn resolve_ref<'a>(
755    ref_str: &str,
756    root_schema: &'a serde_json::Value,
757) -> Option<&'a serde_json::Value> {
758    let suffix = ref_str.strip_prefix("#/")?;
759    let mut current = root_schema;
760    for segment in suffix.split('/') {
761        let decoded = segment.replace("~1", "/").replace("~0", "~");
762        current = current.get(&decoded)?;
763    }
764    Some(current)
765}
766
767fn resolve_effective_type(
768    schema: &serde_json::Value,
769    root_schema: &serde_json::Value,
770    depth: usize,
771) -> EffectiveType {
772    if depth > MAX_REF_DEPTH {
773        return EffectiveType::Unknown;
774    }
775
776    if let Some(ref_str) = schema.get("$ref").and_then(|r| r.as_str()) {
777        if let Some(target) = resolve_ref(ref_str, root_schema) {
778            return resolve_effective_type(target, root_schema, depth + 1);
779        }
780        return EffectiveType::Unknown;
781    }
782
783    match schema.get("type") {
784        Some(serde_json::Value::String(s)) => return type_str_to_effective(s),
785        Some(serde_json::Value::Array(arr)) => {
786            // Prefer aggregate types when present; fall back to first non-null scalar.
787            for t in arr {
788                if let Some(s) = t.as_str()
789                    && (s == "array" || s == "object")
790                {
791                    return type_str_to_effective(s);
792                }
793            }
794            for t in arr {
795                if let Some(s) = t.as_str()
796                    && s != "null"
797                {
798                    return type_str_to_effective(s);
799                }
800            }
801        }
802        _ => {}
803    }
804
805    for key in ["oneOf", "anyOf", "allOf"] {
806        if let Some(branches) = schema.get(key).and_then(|v| v.as_array()) {
807            for branch in branches {
808                let et = resolve_effective_type(branch, root_schema, depth + 1);
809                if matches!(et, EffectiveType::Array | EffectiveType::Object) {
810                    return et;
811                }
812            }
813            for branch in branches {
814                let et = resolve_effective_type(branch, root_schema, depth + 1);
815                if !matches!(et, EffectiveType::Unknown) {
816                    return et;
817                }
818            }
819        }
820    }
821
822    if schema.get("items").is_some() {
823        return EffectiveType::Array;
824    }
825    if schema.get("properties").is_some() {
826        return EffectiveType::Object;
827    }
828
829    EffectiveType::Unknown
830}
831
832/// Coerce a raw string value to the type declared in the property schema.
833fn coerce_value(
834    raw: &str,
835    prop_schema: Option<&serde_json::Value>,
836    root_schema: &serde_json::Value,
837) -> serde_json::Value {
838    let effective = prop_schema
839        .map(|s| resolve_effective_type(s, root_schema, 0))
840        .unwrap_or(EffectiveType::Unknown);
841
842    match effective {
843        EffectiveType::Integer => raw
844            .parse::<i64>()
845            .map(serde_json::Value::from)
846            .unwrap_or_else(|_| serde_json::Value::String(raw.to_string())),
847        EffectiveType::Number => raw
848            .parse::<f64>()
849            .map(|n| serde_json::json!(n))
850            .unwrap_or_else(|_| serde_json::Value::String(raw.to_string())),
851        EffectiveType::Boolean => match raw {
852            "true" | "1" | "yes" => serde_json::Value::Bool(true),
853            "false" | "0" | "no" => serde_json::Value::Bool(false),
854            _ => serde_json::Value::String(raw.to_string()),
855        },
856        EffectiveType::Array | EffectiveType::Object => {
857            let trimmed = raw.trim_start();
858            if (trimmed.starts_with('[') || trimmed.starts_with('{'))
859                && let Some(parsed) = parse_aggregate_json_value(raw)
860            {
861                return parsed;
862            }
863            serde_json::Value::String(raw.to_string())
864        }
865        EffectiveType::String | EffectiveType::Unknown => {
866            serde_json::Value::String(raw.to_string())
867        }
868    }
869}
870
871fn parse_aggregate_json_value(raw: &str) -> Option<serde_json::Value> {
872    if raw.len() > MAX_AGGREGATE_FLAG_JSON_BYTES {
873        return None;
874    }
875    serde_json::from_str::<serde_json::Value>(raw).ok()
876}
877
878/// Generate a usage hint from schema properties: `--id <integer> --name <string>`.
879/// Aggregate flags advertise both JSON and `key=value` forms in their hint.
880pub(crate) fn usage_from_schema(schema: &serde_json::Value) -> Option<String> {
881    let props = schema.get("properties")?.as_object()?;
882    if props.is_empty() {
883        return None;
884    }
885    let flags: Vec<String> = props
886        .iter()
887        .map(|(key, prop)| {
888            let hint = match resolve_effective_type(prop, schema, 0) {
889                EffectiveType::Object => "<json|key=value...>".to_string(),
890                EffectiveType::Array => {
891                    let items = prop.get("items");
892                    let items_eff = items
893                        .map(|s| resolve_effective_type(s, schema, 0))
894                        .unwrap_or(EffectiveType::Unknown);
895                    if items_eff == EffectiveType::Object {
896                        "<json|key=value...>".to_string()
897                    } else {
898                        "<json|a,b,c>".to_string()
899                    }
900                }
901                EffectiveType::Integer => "<integer>".to_string(),
902                EffectiveType::Number => "<number>".to_string(),
903                EffectiveType::Boolean => "<boolean>".to_string(),
904                EffectiveType::String => "<string>".to_string(),
905                EffectiveType::Unknown => {
906                    let ty = prop.get("type").and_then(|t| t.as_str()).unwrap_or("value");
907                    format!("<{ty}>")
908                }
909            };
910            format!("--{key} {hint}")
911        })
912        .collect();
913    Some(flags.join(" "))
914}
915
916#[cfg(test)]
917mod tests {
918    use super::*;
919
920    #[test]
921    fn test_unknown_flag_kept_as_string_by_default() {
922        // Open schema (no `additionalProperties: false`): unchanged behaviour.
923        let schema = serde_json::json!({
924            "type": "object",
925            "properties": { "limit": {"type": "integer"} }
926        });
927        let args = vec!["--limti".to_string(), "10".to_string()];
928        let result = parse_flags(&args, &schema).unwrap();
929        assert_eq!(result["limti"], "10");
930    }
931
932    #[test]
933    fn test_closed_schema_rejects_unknown_flag() {
934        let schema = serde_json::json!({
935            "type": "object",
936            "properties": { "limit": {"type": "integer"}, "offset": {"type": "integer"} },
937            "additionalProperties": false
938        });
939        let args = vec!["--limti".to_string(), "10".to_string()];
940        let err = parse_flags(&args, &schema).unwrap_err();
941        assert!(err.contains("unknown flag: --limti"), "got: {err}");
942        assert!(err.contains("--limit"), "lists known flags: {err}");
943        assert!(err.contains("--offset"), "lists known flags: {err}");
944    }
945
946    #[test]
947    fn test_closed_schema_rejects_unknown_equals_flag() {
948        let schema = serde_json::json!({
949            "type": "object",
950            "properties": { "limit": {"type": "integer"} },
951            "additionalProperties": false
952        });
953        let args = vec!["--limti=10".to_string()];
954        let err = parse_flags(&args, &schema).unwrap_err();
955        assert!(err.contains("unknown flag: --limti"), "got: {err}");
956    }
957
958    #[test]
959    fn test_closed_schema_accepts_known_flags() {
960        let schema = serde_json::json!({
961            "type": "object",
962            "properties": { "limit": {"type": "integer"}, "name": {"type": "string"} },
963            "additionalProperties": false
964        });
965        let args = vec![
966            "--limit".to_string(),
967            "10".to_string(),
968            "--name=alice".to_string(),
969        ];
970        let result = parse_flags(&args, &schema).unwrap();
971        assert_eq!(result["limit"], 10);
972        assert_eq!(result["name"], "alice");
973    }
974
975    #[test]
976    fn test_closed_schema_without_properties_takes_no_flags() {
977        let schema = serde_json::json!({
978            "type": "object",
979            "additionalProperties": false
980        });
981        let args = vec!["--anything".to_string(), "x".to_string()];
982        let err = parse_flags(&args, &schema).unwrap_err();
983        assert!(err.contains("takes no flags"), "got: {err}");
984    }
985
986    #[test]
987    fn test_parse_flags_basic() {
988        let schema = serde_json::json!({
989            "type": "object",
990            "properties": {
991                "id": {"type": "integer"},
992                "name": {"type": "string"},
993                "verbose": {"type": "boolean"}
994            }
995        });
996        let args = vec![
997            "--id".to_string(),
998            "42".to_string(),
999            "--name".to_string(),
1000            "Alice".to_string(),
1001            "--verbose".to_string(),
1002        ];
1003        let result = parse_flags(&args, &schema).unwrap();
1004        assert_eq!(result["id"], 42);
1005        assert_eq!(result["name"], "Alice");
1006        assert_eq!(result["verbose"], true);
1007    }
1008
1009    #[test]
1010    fn test_parse_flags_equals_syntax() {
1011        let schema = serde_json::json!({
1012            "type": "object",
1013            "properties": {"id": {"type": "integer"}}
1014        });
1015        let args = vec!["--id=42".to_string()];
1016        let result = parse_flags(&args, &schema).unwrap();
1017        assert_eq!(result["id"], 42);
1018    }
1019
1020    #[test]
1021    fn test_parse_flags_json_array_string() {
1022        let schema = serde_json::json!({
1023            "type": "object",
1024            "properties": {"tags": {"type": "array", "items": {"type": "string"}}}
1025        });
1026        let args = vec!["--tags".to_string(), r#"["a","b","c"]"#.to_string()];
1027        let result = parse_flags(&args, &schema).unwrap();
1028        assert_eq!(result["tags"], serde_json::json!(["a", "b", "c"]));
1029    }
1030
1031    #[test]
1032    fn test_parse_flags_json_object_string() {
1033        let schema = serde_json::json!({
1034            "type": "object",
1035            "properties": {"server": {"type": "object"}}
1036        });
1037        let args = vec![
1038            "--server".to_string(),
1039            r#"{"name":"foo","port":8080}"#.to_string(),
1040        ];
1041        let result = parse_flags(&args, &schema).unwrap();
1042        assert_eq!(
1043            result["server"],
1044            serde_json::json!({"name": "foo", "port": 8080})
1045        );
1046    }
1047
1048    #[test]
1049    fn test_parse_flags_nullable_array() {
1050        // utoipa-style nullable: type: ["array", "null"]
1051        let schema = serde_json::json!({
1052            "type": "object",
1053            "properties": {
1054                "tags": {"type": ["array", "null"], "items": {"type": "string"}}
1055            }
1056        });
1057        let args = vec!["--tags".to_string(), r#"["x","y"]"#.to_string()];
1058        let result = parse_flags(&args, &schema).unwrap();
1059        assert_eq!(result["tags"], serde_json::json!(["x", "y"]));
1060    }
1061
1062    #[test]
1063    fn test_parse_flags_oneof_null_and_ref() {
1064        let schema = serde_json::json!({
1065            "type": "object",
1066            "properties": {
1067                "config": {
1068                    "oneOf": [
1069                        {"type": "null"},
1070                        {"$ref": "#/$defs/Config"}
1071                    ]
1072                }
1073            },
1074            "$defs": {
1075                "Config": {"type": "object", "properties": {"k": {"type": "string"}}}
1076            }
1077        });
1078        let args = vec!["--config".to_string(), r#"{"k":"v"}"#.to_string()];
1079        let result = parse_flags(&args, &schema).unwrap();
1080        assert_eq!(result["config"], serde_json::json!({"k": "v"}));
1081    }
1082
1083    #[test]
1084    fn test_parse_flags_allof_composition() {
1085        let schema = serde_json::json!({
1086            "type": "object",
1087            "properties": {
1088                "data": {
1089                    "allOf": [
1090                        {"type": "object"},
1091                        {"properties": {"x": {"type": "integer"}}}
1092                    ]
1093                }
1094            }
1095        });
1096        let args = vec!["--data".to_string(), r#"{"x":1}"#.to_string()];
1097        let result = parse_flags(&args, &schema).unwrap();
1098        assert_eq!(result["data"], serde_json::json!({"x": 1}));
1099    }
1100
1101    #[test]
1102    fn test_parse_flags_invalid_json_left_as_string() {
1103        let schema = serde_json::json!({
1104            "type": "object",
1105            "properties": {"tags": {"type": "array"}}
1106        });
1107        // Malformed JSON — stays as raw string; serde produces the real error downstream.
1108        let args = vec!["--tags".to_string(), "[1, 2,".to_string()];
1109        let result = parse_flags(&args, &schema).unwrap();
1110        assert_eq!(
1111            result["tags"],
1112            serde_json::Value::String("[1, 2,".to_string())
1113        );
1114    }
1115
1116    #[test]
1117    fn test_parse_flags_scalar_string_unchanged() {
1118        let schema = serde_json::json!({
1119            "type": "object",
1120            "properties": {"name": {"type": "string"}}
1121        });
1122        let args = vec!["--name".to_string(), "Alice".to_string()];
1123        let result = parse_flags(&args, &schema).unwrap();
1124        assert_eq!(result["name"], "Alice");
1125    }
1126
1127    #[test]
1128    fn test_parse_flags_implicit_array_from_items() {
1129        let schema = serde_json::json!({
1130            "type": "object",
1131            "properties": {"tags": {"items": {"type": "string"}}}
1132        });
1133        let args = vec!["--tags".to_string(), r#"["p","q"]"#.to_string()];
1134        let result = parse_flags(&args, &schema).unwrap();
1135        assert_eq!(result["tags"], serde_json::json!(["p", "q"]));
1136    }
1137
1138    #[test]
1139    fn test_parse_flags_implicit_object_from_properties() {
1140        let schema = serde_json::json!({
1141            "type": "object",
1142            "properties": {
1143                "server": {"properties": {"port": {"type": "integer"}}}
1144            }
1145        });
1146        let args = vec!["--server".to_string(), r#"{"port":80}"#.to_string()];
1147        let result = parse_flags(&args, &schema).unwrap();
1148        assert_eq!(result["server"], serde_json::json!({"port": 80}));
1149    }
1150
1151    #[test]
1152    fn test_parse_flags_ref_into_defs() {
1153        let schema = serde_json::json!({
1154            "type": "object",
1155            "properties": {"items": {"$ref": "#/$defs/Items"}},
1156            "$defs": {
1157                "Items": {"type": "array", "items": {"type": "integer"}}
1158            }
1159        });
1160        let args = vec!["--items".to_string(), "[1,2,3]".to_string()];
1161        let result = parse_flags(&args, &schema).unwrap();
1162        assert_eq!(result["items"], serde_json::json!([1, 2, 3]));
1163    }
1164
1165    #[test]
1166    fn test_parse_flags_ref_into_definitions() {
1167        // Older draft uses `definitions` instead of `$defs`.
1168        let schema = serde_json::json!({
1169            "type": "object",
1170            "properties": {"items": {"$ref": "#/definitions/Items"}},
1171            "definitions": {
1172                "Items": {"type": "array"}
1173            }
1174        });
1175        let args = vec!["--items".to_string(), "[1,2]".to_string()];
1176        let result = parse_flags(&args, &schema).unwrap();
1177        assert_eq!(result["items"], serde_json::json!([1, 2]));
1178    }
1179
1180    #[test]
1181    fn test_parse_flags_ref_cycle_bounded() {
1182        // Cyclical $ref must not stack-overflow.
1183        let schema = serde_json::json!({
1184            "type": "object",
1185            "properties": {"x": {"$ref": "#/$defs/A"}},
1186            "$defs": {
1187                "A": {"$ref": "#/$defs/B"},
1188                "B": {"$ref": "#/$defs/A"}
1189            }
1190        });
1191        let args = vec!["--x".to_string(), "value".to_string()];
1192        let result = parse_flags(&args, &schema).unwrap();
1193        // Falls back to string when ref cycle is detected.
1194        assert_eq!(result["x"], "value");
1195    }
1196
1197    #[test]
1198    fn test_parse_flags_array_value_not_starting_with_bracket() {
1199        // Aggregate scalar array: comma-split (single token, no comma → length-1 array).
1200        let schema = serde_json::json!({
1201            "type": "object",
1202            "properties": {"tags": {"type": "array", "items": {"type": "string"}}}
1203        });
1204        let args = vec!["--tags".to_string(), "abc".to_string()];
1205        let result = parse_flags(&args, &schema).unwrap();
1206        assert_eq!(result["tags"], serde_json::json!(["abc"]));
1207    }
1208
1209    #[test]
1210    fn test_parse_flags_array_missing_value_is_error() {
1211        let schema = serde_json::json!({
1212            "type": "object",
1213            "properties": {"tags": {"type": "array", "items": {"type": "string"}}}
1214        });
1215        let args = vec!["--tags".to_string()];
1216        let err = parse_flags(&args, &schema).unwrap_err();
1217        assert_eq!(err, "--tags: missing value");
1218    }
1219
1220    #[test]
1221    fn test_parse_flags_array_missing_value_before_next_flag_is_error() {
1222        let schema = serde_json::json!({
1223            "type": "object",
1224            "properties": {
1225                "tags": {"type": "array", "items": {"type": "string"}},
1226                "name": {"type": "string"}
1227            }
1228        });
1229        let args = vec![
1230            "--tags".to_string(),
1231            "--name".to_string(),
1232            "alice".to_string(),
1233        ];
1234        let err = parse_flags(&args, &schema).unwrap_err();
1235        assert_eq!(err, "--tags: missing value");
1236    }
1237
1238    #[test]
1239    fn test_parse_flags_pair_object_single() {
1240        let schema = serde_json::json!({
1241            "type": "object",
1242            "properties": {
1243                "server": {
1244                    "type": "object",
1245                    "properties": {
1246                        "name": {"type": "string"},
1247                        "url": {"type": "string"}
1248                    }
1249                }
1250            }
1251        });
1252        let args = vec![
1253            "--server".to_string(),
1254            "name=foo".to_string(),
1255            "url=https://example.com".to_string(),
1256        ];
1257        let result = parse_flags(&args, &schema).unwrap();
1258        assert_eq!(
1259            result["server"],
1260            serde_json::json!({"name": "foo", "url": "https://example.com"})
1261        );
1262    }
1263
1264    #[test]
1265    fn test_parse_flags_pair_array_of_objects_repeated() {
1266        let schema = serde_json::json!({
1267            "type": "object",
1268            "properties": {
1269                "endpoint": {
1270                    "type": "array",
1271                    "items": {
1272                        "type": "object",
1273                        "properties": {
1274                            "name": {"type": "string"},
1275                            "url": {"type": "string"}
1276                        }
1277                    }
1278                }
1279            }
1280        });
1281        let args = vec![
1282            "--endpoint".to_string(),
1283            "name=a".to_string(),
1284            "url=u1".to_string(),
1285            "--endpoint".to_string(),
1286            "name=b".to_string(),
1287            "url=u2".to_string(),
1288        ];
1289        let result = parse_flags(&args, &schema).unwrap();
1290        assert_eq!(
1291            result["endpoint"],
1292            serde_json::json!([
1293                {"name": "a", "url": "u1"},
1294                {"name": "b", "url": "u2"}
1295            ])
1296        );
1297    }
1298
1299    #[test]
1300    fn test_parse_flags_array_string_comma_split() {
1301        let schema = serde_json::json!({
1302            "type": "object",
1303            "properties": {
1304                "tags": {"type": "array", "items": {"type": "string"}}
1305            }
1306        });
1307        let args = vec!["--tags".to_string(), "a,b,c".to_string()];
1308        let result = parse_flags(&args, &schema).unwrap();
1309        assert_eq!(result["tags"], serde_json::json!(["a", "b", "c"]));
1310    }
1311
1312    #[test]
1313    fn test_parse_flags_array_string_repeated_appends() {
1314        let schema = serde_json::json!({
1315            "type": "object",
1316            "properties": {
1317                "tags": {"type": "array", "items": {"type": "string"}}
1318            }
1319        });
1320        let args = vec![
1321            "--tags".to_string(),
1322            "x".to_string(),
1323            "--tags".to_string(),
1324            "y".to_string(),
1325        ];
1326        let result = parse_flags(&args, &schema).unwrap();
1327        assert_eq!(result["tags"], serde_json::json!(["x", "y"]));
1328    }
1329
1330    #[test]
1331    fn test_parse_flags_pair_nested_type_coercion() {
1332        let schema = serde_json::json!({
1333            "type": "object",
1334            "properties": {
1335                "server": {
1336                    "type": "object",
1337                    "properties": {
1338                        "enabled": {"type": "boolean"},
1339                        "port": {"type": "integer"}
1340                    }
1341                }
1342            }
1343        });
1344        let args = vec![
1345            "--server".to_string(),
1346            "enabled=true".to_string(),
1347            "port=8080".to_string(),
1348        ];
1349        let result = parse_flags(&args, &schema).unwrap();
1350        assert_eq!(
1351            result["server"],
1352            serde_json::json!({"enabled": true, "port": 8080})
1353        );
1354    }
1355
1356    #[test]
1357    fn test_parse_flags_pair_unknown_key_errors() {
1358        let schema = serde_json::json!({
1359            "type": "object",
1360            "properties": {
1361                "server": {
1362                    "type": "object",
1363                    "properties": {
1364                        "name": {"type": "string"}
1365                    }
1366                }
1367            }
1368        });
1369        let args = vec!["--server".to_string(), "bogus=foo".to_string()];
1370        let err = parse_flags(&args, &schema).unwrap_err();
1371        assert!(err.contains("unknown key"), "got: {err}");
1372        assert!(err.contains("bogus"), "got: {err}");
1373    }
1374
1375    #[test]
1376    fn test_parse_flags_object_json_form_unchanged() {
1377        let schema = serde_json::json!({
1378            "type": "object",
1379            "properties": {
1380                "server": {
1381                    "type": "object",
1382                    "properties": {"name": {"type": "string"}}
1383                }
1384            }
1385        });
1386        let args = vec!["--server".to_string(), r#"{"name":"foo"}"#.to_string()];
1387        let result = parse_flags(&args, &schema).unwrap();
1388        assert_eq!(result["server"], serde_json::json!({"name": "foo"}));
1389    }
1390
1391    #[test]
1392    fn test_parse_flags_pair_mixed_with_json_rejected() {
1393        // After consuming JSON `{...}` for --server, a stray `name=foo` token is
1394        // not a flag and not part of the consumed JSON value, so parse_flags
1395        // rejects it at the top of the loop.
1396        let schema = serde_json::json!({
1397            "type": "object",
1398            "properties": {
1399                "server": {
1400                    "type": "object",
1401                    "properties": {"name": {"type": "string"}}
1402                }
1403            }
1404        });
1405        let args = vec![
1406            "--server".to_string(),
1407            r#"{"name":"foo"}"#.to_string(),
1408            "name=bar".to_string(),
1409        ];
1410        let err = parse_flags(&args, &schema).unwrap_err();
1411        assert!(err.contains("expected --flag"), "got: {err}");
1412    }
1413
1414    #[test]
1415    fn test_parse_flags_array_of_objects_json_then_pair_appends() {
1416        // JSON for first invocation, pairs for second — both append.
1417        let schema = serde_json::json!({
1418            "type": "object",
1419            "properties": {
1420                "endpoint": {
1421                    "type": "array",
1422                    "items": {
1423                        "type": "object",
1424                        "properties": {
1425                            "name": {"type": "string"}
1426                        }
1427                    }
1428                }
1429            }
1430        });
1431        let args = vec![
1432            "--endpoint".to_string(),
1433            r#"{"name":"j"}"#.to_string(),
1434            "--endpoint".to_string(),
1435            "name=p".to_string(),
1436        ];
1437        let result = parse_flags(&args, &schema).unwrap();
1438        assert_eq!(
1439            result["endpoint"],
1440            serde_json::json!([{"name": "j"}, {"name": "p"}])
1441        );
1442    }
1443
1444    #[test]
1445    fn test_parse_flags_array_int_comma_split_coerced() {
1446        let schema = serde_json::json!({
1447            "type": "object",
1448            "properties": {
1449                "ids": {"type": "array", "items": {"type": "integer"}}
1450            }
1451        });
1452        let args = vec!["--ids".to_string(), "1,2,3".to_string()];
1453        let result = parse_flags(&args, &schema).unwrap();
1454        assert_eq!(result["ids"], serde_json::json!([1, 2, 3]));
1455    }
1456
1457    #[test]
1458    fn test_parse_flags_comma_split_array_item_limit() {
1459        let schema = serde_json::json!({
1460            "type": "object",
1461            "properties": {"tags": {"type": "array", "items": {"type": "string"}}}
1462        });
1463        let too_many_empty_items = ",".repeat(MAX_ARRAY_FLAG_ITEMS);
1464        let args = vec!["--tags".to_string(), too_many_empty_items];
1465        let err = parse_flags(&args, &schema).unwrap_err();
1466        assert!(err.contains("maximum"), "got: {err}");
1467    }
1468
1469    #[test]
1470    fn test_parse_flags_repeated_array_item_limit() {
1471        let schema = serde_json::json!({
1472            "type": "object",
1473            "properties": {"tags": {"type": "array", "items": {"type": "string"}}}
1474        });
1475        let at_limit = std::iter::repeat_n("x", MAX_ARRAY_FLAG_ITEMS)
1476            .collect::<Vec<_>>()
1477            .join(",");
1478        let args = vec![
1479            "--tags".to_string(),
1480            at_limit,
1481            "--tags".to_string(),
1482            "overflow".to_string(),
1483        ];
1484        let err = parse_flags(&args, &schema).unwrap_err();
1485        assert!(err.contains("maximum"), "got: {err}");
1486    }
1487
1488    #[test]
1489    fn test_parse_flags_total_parsed_bytes_limit() {
1490        let schema = serde_json::json!({
1491            "type": "object",
1492            "properties": {"name": {"type": "string"}}
1493        });
1494        let args = vec!["--name".to_string(), "x".repeat(MAX_PARSED_FLAG_BYTES + 1)];
1495        let err = parse_flags(&args, &schema).unwrap_err();
1496        assert!(err.contains("parsed flag data exceeds"), "got: {err}");
1497    }
1498
1499    #[test]
1500    fn test_parse_flags_large_json_array_stays_string() {
1501        let schema = serde_json::json!({
1502            "type": "object",
1503            "properties": {"tags": {"type": "array", "items": {"type": "string"}}}
1504        });
1505        let large = format!(
1506            "[{}]",
1507            "\"a\",".repeat((MAX_AGGREGATE_FLAG_JSON_BYTES / 4) + 1)
1508        );
1509        let args = vec!["--tags".to_string(), large.clone()];
1510        let result = parse_flags(&args, &schema).unwrap();
1511        assert_eq!(result["tags"], serde_json::Value::String(large));
1512    }
1513
1514    #[test]
1515    fn test_usage_from_schema_advertises_both_forms() {
1516        let schema = serde_json::json!({
1517            "type": "object",
1518            "properties": {
1519                "server": {"type": "object"},
1520                "tags": {"type": "array", "items": {"type": "string"}},
1521                "id": {"type": "integer"}
1522            }
1523        });
1524        let usage = usage_from_schema(&schema).expect("usage");
1525        assert!(
1526            usage.contains("--server <json|key=value...>"),
1527            "got: {usage}"
1528        );
1529        assert!(usage.contains("--tags <json|a,b,c>"), "got: {usage}");
1530        assert!(usage.contains("--id <integer>"), "got: {usage}");
1531    }
1532
1533    #[test]
1534    fn test_tool_impl_sync() {
1535        let tool = ToolImpl::new(ToolDef::new("greet", "Greet a user").with_schema(
1536            serde_json::json!({
1537                "type": "object",
1538                "properties": { "name": {"type": "string"} }
1539            }),
1540        ))
1541        .with_exec_sync(|args| {
1542            let name = args.param_str("name").unwrap_or("world");
1543            Ok(format!("hello {name}\n"))
1544        });
1545
1546        assert!(tool.exec_sync.is_some());
1547        assert!(tool.exec.is_none());
1548        assert!(tool.sanitize_errors);
1549        assert_eq!(tool.def.name, "greet");
1550    }
1551
1552    /// Run `tool` as a registered builtin. Uses the public `Bash` API: the
1553    /// interpreter's `BuiltinContext` test constructor is core-internal.
1554    async fn run_builtin(tool: ToolImpl, script: &str) -> Result<ExecResult> {
1555        let name = tool.def.name.clone();
1556        let mut bash = bashkit::Bash::builder()
1557            .builtin(&name, Box::new(tool))
1558            .build();
1559        bash.exec(script).await
1560    }
1561
1562    #[tokio::test]
1563    async fn test_tool_impl_as_builtin() {
1564        let tool = ToolImpl::new(ToolDef::new("greet", "Greet a user").with_schema(
1565            serde_json::json!({
1566                "type": "object",
1567                "properties": { "name": {"type": "string"} }
1568            }),
1569        ))
1570        .with_exec_sync(|args| {
1571            let name = args.param_str("name").unwrap_or("world");
1572            Ok(format!("hello {name}\n"))
1573        });
1574
1575        // Verify it works as a Builtin
1576        let result = run_builtin(tool, "greet --name Alice").await.unwrap();
1577        assert_eq!(result.stdout, "hello Alice\n");
1578        assert_eq!(result.exit_code, 0);
1579    }
1580
1581    #[tokio::test]
1582    async fn test_tool_impl_as_builtin_sanitizes_callback_errors() {
1583        let secret = "postgres://user:secret@internal-db/private";
1584        let tool =
1585            ToolImpl::new(ToolDef::new("lookup", "Lookup record")).with_exec_sync(move |_| {
1586                Err(format!(
1587                    "backend failed for {secret} at /srv/app/src/client.rs:42"
1588                ))
1589            });
1590
1591        let result = run_builtin(tool, "lookup").await.unwrap();
1592
1593        assert_eq!(result.exit_code, 1);
1594        assert_eq!(result.stderr, "lookup: callback failed\n");
1595        assert!(!result.stderr.contains("secret"));
1596        assert!(!result.stderr.contains("internal-db"));
1597        assert!(!result.stderr.contains("/srv/app"));
1598    }
1599
1600    #[tokio::test]
1601    async fn test_tool_impl_as_builtin_can_opt_out_of_error_sanitization() {
1602        let tool = ToolImpl::new(ToolDef::new("lookup", "Lookup record"))
1603            .with_exec_sync(|_| Err("raw diagnostic".to_string()))
1604            .sanitize_errors(false);
1605
1606        let result = run_builtin(tool, "lookup").await.unwrap();
1607
1608        assert_eq!(result.exit_code, 1);
1609        assert_eq!(result.stderr, "raw diagnostic");
1610    }
1611
1612    #[tokio::test]
1613    async fn test_tool_impl_async_exec() {
1614        let tool =
1615            ToolImpl::new(ToolDef::new("echo_async", "Async echo")).with_exec(|args| async move {
1616                let msg = args.stdin.unwrap_or_default();
1617                Ok(format!("async: {msg}"))
1618            });
1619
1620        assert!(tool.exec.is_some());
1621        assert!(tool.exec_sync.is_none());
1622    }
1623
1624    #[tokio::test]
1625    async fn test_tool_impl_no_exec_errors() {
1626        let tool = ToolImpl::new(ToolDef::new("empty", "No exec"));
1627
1628        let result = run_builtin(tool, "empty").await;
1629        assert!(result.is_err());
1630    }
1631}