Skip to main content

lean_ctx_server/server/
tool_trait.rs

1// SPDX-License-Identifier: Apache-2.0
2use rmcp::ErrorData;
3use rmcp::model::{ContentBlock, Tool};
4use serde_json::{Map, Value};
5
6/// Internal handler-to-dispatch signal for an executed operation that failed.
7/// Unlike invalid arguments, its body can contain file excerpts and must pass
8/// the full content pipeline before becoming an MCP `isError` result.
9pub const TOOL_EXECUTION_ERROR: rmcp::model::ErrorCode = rmcp::model::ErrorCode(-32071);
10
11pub(crate) fn tool_execution_error(message: String) -> ErrorData {
12    ErrorData::new(TOOL_EXECUTION_ERROR, message, None)
13}
14
15/// Path arguments shared by MCP dispatch and the embedded Engine boundary.
16pub const PATH_LIKE_KEYS: &[&str] = &[
17    "path",
18    "project_root",
19    "root",
20    "file",
21    "directory",
22    "dir",
23    "target",
24    "source",
25    "destination",
26    "old_path",
27    "new_path",
28    "file_path",
29    "from",
30    "to",
31    "base_path",
32    "config_path",
33    "output",
34];
35
36/// Stable states exposed by `ctx_shell(background_action="status")`.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum BackgroundJobState {
39    Running,
40    Completed,
41    Failed,
42    Cancelled,
43}
44
45impl BackgroundJobState {
46    pub fn as_str(self) -> &'static str {
47        match self {
48            Self::Running => "running",
49            Self::Completed => "completed",
50            Self::Failed => "failed",
51            Self::Cancelled => "cancelled",
52        }
53    }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct BackgroundDisplay {
58    pub header: String,
59    pub footer: Option<String>,
60}
61
62/// Protocol metadata that must survive output compression and archiving.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct BackgroundShellOutcome {
65    pub state: BackgroundJobState,
66    pub exit_code: Option<i32>,
67    pub job_id: String,
68    pub archive_id: Option<String>,
69    pub archive_truncated: Option<bool>,
70    pub captured_chars: Option<usize>,
71    pub archived_chars: Option<usize>,
72    pub summary: String,
73    pub is_error: bool,
74    pub display: Option<BackgroundDisplay>,
75}
76
77/// A background job lookup failed without evidence of a lifecycle state.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct BackgroundLookupError {
80    pub job_id: String,
81    pub code: String,
82    pub reason: String,
83}
84
85/// Outcome of a shell execution, carried alongside the rendered text so the
86/// MCP dispatch layer can surface failures in protocol metadata instead of
87/// only as an `[exit:N]` text footer (GitHub #389: clients had no programmatic
88/// way to detect ctx_shell failures and resorted to fragile regex matching).
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum ShellOutcome {
91    /// The command ran; carries its real exit code (0 = success).
92    Exit(i32),
93    /// The command never ran (allowlist/validation rejection, or a
94    /// precondition failure such as an unreadable/oversized input file).
95    Blocked,
96    /// A managed background job status. Unlike a foreground `Exit(1)`, a
97    /// terminal failed job is never reclassified as grep-like result data.
98    Background(BackgroundShellOutcome),
99    /// The requested background job is unknown or its retained verdict expired.
100    BackgroundLookupError(BackgroundLookupError),
101}
102
103impl ShellOutcome {
104    /// Whether this outcome must be reported as a tool error (`isError: true`).
105    pub fn is_error(&self) -> bool {
106        match self {
107            ShellOutcome::Exit(code) => *code != 0,
108            ShellOutcome::Blocked | ShellOutcome::BackgroundLookupError(_) => true,
109            ShellOutcome::Background(outcome) => outcome.is_error,
110        }
111    }
112
113    /// Structured payload for `CallToolResult.structuredContent`, so guards
114    /// can read state/exit/archive data instead of parsing output text.
115    /// Ordinary foreground success (exit 0) stays token-neutral; background
116    /// status always remains structured because clients may filter its text.
117    pub fn structured(&self) -> Option<serde_json::Value> {
118        match self {
119            ShellOutcome::Exit(0) => None,
120            ShellOutcome::Exit(code) => Some(serde_json::json!({ "exitCode": code })),
121            ShellOutcome::Blocked => Some(serde_json::json!({ "blocked": true })),
122            ShellOutcome::Background(outcome) => {
123                let mut fields = serde_json::Map::new();
124                fields.insert(
125                    "state".to_string(),
126                    serde_json::json!(outcome.state.as_str()),
127                );
128                fields.insert("jobId".to_string(), serde_json::json!(outcome.job_id));
129                fields.insert("summary".to_string(), serde_json::json!(outcome.summary));
130                if let Some(exit_code) = outcome.exit_code {
131                    fields.insert("exitCode".to_string(), serde_json::json!(exit_code));
132                }
133                if let Some(archive_id) = &outcome.archive_id {
134                    fields.insert("archiveId".to_string(), serde_json::json!(archive_id));
135                }
136                if let Some(archive_truncated) = outcome.archive_truncated {
137                    fields.insert(
138                        "archiveTruncated".to_string(),
139                        serde_json::json!(archive_truncated),
140                    );
141                }
142                if let Some(captured_chars) = outcome.captured_chars {
143                    fields.insert(
144                        "capturedChars".to_string(),
145                        serde_json::json!(captured_chars),
146                    );
147                }
148                if let Some(archived_chars) = outcome.archived_chars {
149                    fields.insert(
150                        "archivedChars".to_string(),
151                        serde_json::json!(archived_chars),
152                    );
153                }
154                Some(serde_json::Value::Object(fields))
155            }
156            ShellOutcome::BackgroundLookupError(error) => Some(serde_json::json!({
157                "jobId": error.job_id,
158                "errorCode": error.code,
159                "reason": error.reason,
160            })),
161        }
162    }
163
164    /// Whether this is a status body that the common pipeline must secure,
165    /// archive and render before delivery.
166    pub fn is_background_status(&self) -> bool {
167        matches!(
168            self,
169            ShellOutcome::Background(BackgroundShellOutcome {
170                display: Some(_),
171                ..
172            })
173        )
174    }
175
176    pub fn is_terminal_background_status(&self) -> bool {
177        matches!(
178            self,
179            ShellOutcome::Background(BackgroundShellOutcome {
180                state: BackgroundJobState::Completed
181                    | BackgroundJobState::Failed
182                    | BackgroundJobState::Cancelled,
183                display: Some(_),
184                ..
185            })
186        )
187    }
188}
189
190/// Result returned by an McpTool handler.
191pub struct ToolOutput {
192    pub text: String,
193    pub original_tokens: usize,
194    pub saved_tokens: usize,
195    pub mode: Option<String>,
196    /// Path associated with the tool call (for record_call_with_path).
197    pub path: Option<String>,
198    /// True when the tool mutated state that clients should know about
199    /// (e.g. dynamic tool categories changed).
200    pub changed: bool,
201    /// Set by shell-executing tools so dispatch can populate `isError` +
202    /// `structuredContent` on the MCP result (GitHub #389). `None` for tools
203    /// that don't run shell commands.
204    pub shell_outcome: Option<ShellOutcome>,
205    /// Override content blocks for non-text responses (e.g. images).
206    /// When set, dispatch uses these instead of wrapping `text` in TextContent.
207    pub content_blocks: Option<Vec<ContentBlock>>,
208}
209
210impl ToolOutput {
211    pub fn simple(text: String) -> Self {
212        Self {
213            text,
214            original_tokens: 0,
215            saved_tokens: 0,
216            mode: None,
217            path: None,
218            changed: false,
219            shell_outcome: None,
220            content_blocks: None,
221        }
222    }
223
224    /// Compact one-line summary for headers_only response verbosity.
225    pub fn to_header_line(&self, tool_name: &str) -> String {
226        let path_str = self.path.as_deref().unwrap_or("—");
227        let mode_str = self.mode.as_deref().unwrap_or("—");
228        let sent = self.original_tokens.saturating_sub(self.saved_tokens);
229        let pct = if self.original_tokens > 0 {
230            (self.saved_tokens as f64 / self.original_tokens as f64 * 100.0) as u32
231        } else {
232            0
233        };
234        format!("[{tool_name}: {path_str}, mode={mode_str}, {sent} tok sent, -{pct}%]")
235    }
236
237    pub fn with_savings(text: String, original: usize, saved: usize) -> Self {
238        Self {
239            text,
240            original_tokens: original,
241            saved_tokens: saved,
242            mode: None,
243            path: None,
244            changed: false,
245            shell_outcome: None,
246            content_blocks: None,
247        }
248    }
249
250    /// Construct a ToolOutput for image/binary content blocks.
251    /// Bypasses all text processing in the dispatch pipeline.
252    pub fn image(blocks: Vec<ContentBlock>, path: String) -> Self {
253        Self {
254            text: String::new(),
255            original_tokens: 0,
256            saved_tokens: 0,
257            mode: Some("image".to_string()),
258            path: Some(path),
259            changed: false,
260            shell_outcome: None,
261            content_blocks: Some(blocks),
262        }
263    }
264}
265
266/// Trait for a self-contained MCP tool. Each tool provides its own schema
267/// definition and handler, eliminating the possibility of schema/handler drift.
268///
269/// This trait is the plugin interface for LcpTools: any implementation can be
270/// registered at runtime via `ToolRegistry::register()`. Future plugin system
271/// will load implementations from shared libraries or subprocess bridges.
272///
273/// Handlers are synchronous because all existing tool handlers are sync.
274/// The async boundary (cache locks, session reads) is handled by the dispatch
275/// layer before calling `handle`.
276pub trait McpTool: Send + Sync {
277    /// Tool name as registered in the MCP protocol (e.g. "ctx_tree").
278    fn name(&self) -> &'static str;
279
280    /// The name of the handler this tool runs, for the session rights'
281    /// `denied_tools` (process-v1 §9): an alias names the tool it delegates
282    /// to, so denying that tool denies every name it is reachable under.
283    fn rights_name(&self) -> &'static str {
284        self.name()
285    }
286
287    /// MCP tool definition including JSON schema. This replaces the
288    /// corresponding entry in `granular_tool_defs()`.
289    fn tool_def(&self) -> Tool;
290
291    /// Execute the tool. Args are the raw JSON-RPC arguments.
292    /// `ctx` provides access to resolved paths and project state.
293    fn handle(&self, args: &Map<String, Value>, ctx: &ToolContext)
294    -> Result<ToolOutput, ErrorData>;
295
296    /// Whether *this* invocation yields a machine-readable payload (e.g. JSON)
297    /// that must reach the client byte-exact and parseable. When `true`, the
298    /// dispatch pipeline suppresses every human-oriented decoration
299    /// (auto-context briefing, verify footer, hints, checkpoints, deprecation
300    /// notices) and terse compression for this call, returning the pure body.
301    /// Keyed on `args` because most tools emit machine-readable output only for
302    /// an opt-in format flag (e.g. `format=json`); defaults to `false` (#990).
303    fn produces_machine_readable(&self, _args: Option<&Map<String, Value>>) -> bool {
304        false
305    }
306
307    /// Attestation that *this* invocation derives its output from the caller's
308    /// arguments and lean-ctx's own state only — it reads no project source,
309    /// provider or stored copy of one. Replay admission relies on it: an
310    /// output with no captured source set is replayed under protection only
311    /// when its tool attests this. The default is `false`: "no source was
312    /// observed" is not proof, an uninstrumented reader publishes none either.
313    /// Override only for a tool whose handler was read for exactly this.
314    fn attests_source_free(&self, _args: Option<&Map<String, Value>>) -> bool {
315        false
316    }
317
318    /// Declaration that *this* invocation reads every project source it
319    /// derives output from through the admitted readers
320    /// (`tools::ctx_read::read_file_*`), on the handler's own thread or on
321    /// workers started with `TaskSpine::spawn_thread` — so that each of them is
322    /// published into the call's source capture. Only then is a captured
323    /// source set the WHOLE set, and only then may it authorize reuse of the
324    /// result (idempotent replay, response cache, source-bound recovery copy).
325    ///
326    /// The default is `false`: a source set captured from a tool that does not
327    /// declare this may be partial (a raw `fs::read`, a thread without the
328    /// capture, a cached body served without a read) and counts as unknown.
329    /// Override only after reading every content path of the handler.
330    fn publishes_every_source(&self, _args: Option<&Map<String, Value>>) -> bool {
331        false
332    }
333}
334
335tokio::task_local! {
336    /// #1781: the cancellation token of the request currently being served,
337    /// scoped by `call_tool` around the whole guarded dispatch.
338    ///
339    /// A task-local rather than a field on the shared server: rmcp spawns one
340    /// task per request and hands each its own `child_token`, so concurrent
341    /// calls would otherwise overwrite one another's token — and cancelling the
342    /// wrong command is worse than not cancelling at all.
343    pub static REQUEST_CT: tokio_util::sync::CancellationToken;
344}
345
346/// Context passed to tool handlers. Contains pre-resolved values that
347/// many tools need, avoiding repeated async lock acquisition inside
348/// handlers. Extended with shared server state for tools that need
349/// cache/session access.
350///
351/// `Clone` exists for per-op delegation (#1088): a batch op with its own
352/// `path` clones the ctx and swaps in that op's resolved path.
353#[derive(Clone)]
354pub struct ToolContext {
355    pub project_root: String,
356    /// Session-scoped trusted roots (MCP `roots/list`, config `extra_roots`),
357    /// snapshotted from the session so sync handlers can honor them without an
358    /// async lock. Empty = single-root jail behaviour (#403).
359    pub extra_roots: Vec<String>,
360    pub minimal: bool,
361    /// Pre-resolved paths keyed by argument name (e.g. "path" -> "/abs/dir").
362    pub resolved_paths: std::collections::HashMap<String, String>,
363    /// CRP mode for compression-aware tools.
364    pub crp_mode: crate::tools::CrpMode,
365    /// Shared cache handle for tools that need read/write access.
366    pub cache: Option<crate::tools::SharedCache>,
367    /// Shared session handle for tools that need session access.
368    pub session: Option<std::sync::Arc<tokio::sync::RwLock<lean_ctx::core::session::SessionState>>>,
369    /// Tool call records for session-aware tools (e.g. ctx_session status).
370    pub tool_calls:
371        Option<std::sync::Arc<tokio::sync::RwLock<Vec<lean_ctx::core::protocol::ToolCallRecord>>>>,
372    /// Current agent identity for multi-agent tools.
373    pub agent_id: Option<std::sync::Arc<tokio::sync::RwLock<Option<String>>>>,
374    /// Active workflow run state.
375    pub workflow:
376        Option<std::sync::Arc<tokio::sync::RwLock<Option<lean_ctx::core::workflow::WorkflowRun>>>>,
377    /// Context ledger for handoff operations.
378    pub ledger:
379        Option<std::sync::Arc<tokio::sync::RwLock<lean_ctx::core::context_ledger::ContextLedger>>>,
380    /// Client name (cursor, claude, etc.).
381    pub client_name: Option<std::sync::Arc<tokio::sync::RwLock<String>>>,
382    /// Optional MCP client role supplied by the session/request context.
383    /// Absent preserves the backward-compatible permissive default.
384    pub client_role: Option<String>,
385    /// Optional shell permission supplied by the session/request context.
386    /// `None` preserves the backward-compatible permissive default.
387    pub shell_access: Option<bool>,
388    /// Pipeline stats for metrics/proof tools.
389    pub pipeline_stats:
390        Option<std::sync::Arc<tokio::sync::RwLock<lean_ctx::core::pipeline::PipelineStats>>>,
391    /// Global call counter for context tools.
392    pub call_count: Option<std::sync::Arc<std::sync::atomic::AtomicUsize>>,
393    /// Autonomy state for search repeat detection.
394    pub autonomy: Option<std::sync::Arc<lean_ctx::core::autonomy::AutonomyState>>,
395    /// Errors from path resolution (PathJail rejection, secret path, etc.).
396    /// Keyed by argument name (e.g. "path" -> "path escapes project root: ...").
397    pub path_errors: std::collections::HashMap<String, String>,
398    /// Shared in-memory BM25 index cache for semantic search.
399    pub bm25_cache: Option<lean_ctx::core::bm25_cache::SharedBm25Cache>,
400    /// MCP progress notification sender for long-running operations.
401    pub progress_sender: Option<crate::server::progress::SharedProgressSender>,
402    /// Cancellation token for this request, when the transport supplies one
403    /// (#1781).
404    ///
405    /// A synchronous tool that waits on a child process cannot observe an
406    /// aborted call without it: the host stops waiting, but the wait loop keeps
407    /// running and holds its thread until its own cap expires. Tools that block
408    /// should consult this and return promptly when it fires.
409    pub cancel: Option<tokio_util::sync::CancellationToken>,
410}
411
412impl Default for ToolContext {
413    /// Minimal context: `project_root` empty, all shared-state handles `None`.
414    /// Single source for the one-shot CLI ctx (`cli::call_cmd::oneshot_ctx`) and
415    /// the `empty_ctx` test helper — adding a field no longer breaks either.
416    fn default() -> Self {
417        Self {
418            project_root: String::new(),
419            extra_roots: Vec::new(),
420            minimal: false,
421            resolved_paths: std::collections::HashMap::new(),
422            crp_mode: crate::tools::CrpMode::Off,
423            cache: None,
424            session: None,
425            tool_calls: None,
426            agent_id: None,
427            workflow: None,
428            ledger: None,
429            client_name: None,
430            client_role: None,
431            shell_access: None,
432            pipeline_stats: None,
433            call_count: None,
434            autonomy: None,
435            path_errors: std::collections::HashMap::new(),
436            bm25_cache: None,
437            progress_sender: None,
438            cancel: None,
439        }
440    }
441}
442
443impl ToolContext {
444    pub fn resolved_path(&self, arg: &str) -> Option<&str> {
445        self.resolved_paths.get(arg).map(String::as_str)
446    }
447
448    /// Returns the path resolution error for a given key, if any.
449    pub fn path_error(&self, key: &str) -> Option<&str> {
450        self.path_errors.get(key).map(String::as_str)
451    }
452
453    /// Sync path resolution using `project_root` + session `extra_roots`. Thin
454    /// wrapper over [`lean_ctx::core::path_resolve::resolve_tool_path_with_roots`]
455    /// for sync tool handlers.
456    pub fn resolve_path_sync(&self, path: &str) -> Result<String, String> {
457        lean_ctx::core::path_resolve::resolve_tool_path_with_roots(
458            Some(&self.project_root),
459            None,
460            path,
461            &self.extra_roots,
462        )
463    }
464
465    /// Default-deny write gate for the read-only tier (#475). Write-capable tool
466    /// handlers must call this with an already-resolved absolute path before
467    /// touching the filesystem; it errors if the path is inside a configured
468    /// `read_only_roots` subtree. A no-op (always `Ok`) when no read-only roots
469    /// are configured, so non-users pay nothing. Thin wrapper over the single
470    /// choke point [`lean_ctx::core::pathjail::enforce_writable`] — the low-level
471    /// atomic writers call the same function, so this is the ergonomic,
472    /// early-error layer, not the only line of defence.
473    pub fn ensure_writable(&self, resolved_path: &str) -> Result<(), String> {
474        lean_ctx::core::pathjail::enforce_writable(std::path::Path::new(resolved_path))
475    }
476}
477
478// ── Arg extraction helpers (mirror server/helpers.rs for standalone use) ──
479
480/// Extract a resolved path from context with differentiated error messages.
481/// Returns descriptive errors for: missing param, PathJail rejection, wrong type.
482pub fn require_resolved_path(
483    ctx: &ToolContext,
484    args: &Map<String, Value>,
485    key: &str,
486) -> Result<String, ErrorData> {
487    if let Some(path) = ctx.resolved_path(key) {
488        return Ok(path.to_string());
489    }
490    if let Some(err) = ctx.path_error(key) {
491        return Err(ErrorData::invalid_params(format!("{key}: {err}"), None));
492    }
493    if let Some(val) = args.get(key)
494        && !val.is_string()
495    {
496        let type_name = match val {
497            Value::Number(_) => "number",
498            Value::Bool(_) => "boolean",
499            Value::Array(_) => "array",
500            Value::Object(_) => "object",
501            Value::Null => "null",
502            Value::String(_) => unreachable!(),
503        };
504        return Err(ErrorData::invalid_params(
505            format!("{key} must be a string, got {type_name}"),
506            None,
507        ));
508    }
509    Err(ErrorData::invalid_params(
510        format!("{key} is required"),
511        None,
512    ))
513}
514
515pub fn get_str(args: &Map<String, Value>, key: &str) -> Option<String> {
516    args.get(key).and_then(|v| v.as_str()).map(String::from)
517}
518
519pub fn get_int(args: &Map<String, Value>, key: &str) -> Option<i64> {
520    args.get(key)
521        .and_then(|v| v.as_i64().or_else(|| v.as_str()?.parse().ok()))
522}
523
524/// Read a non-negative integer argument as `usize`.
525///
526/// Returns `None` for missing or negative values. This avoids the
527/// `negative_i64 as usize` wrap to `usize::MAX`, which previously let an agent
528/// trigger unbounded allocations (e.g. `top_k`, `limit`, `max_results`) → OOM.
529/// Callers should still apply a sensible upper cap on the result.
530pub fn get_usize(args: &Map<String, Value>, key: &str) -> Option<usize> {
531    get_int(args, key).and_then(|n| usize::try_from(n).ok())
532}
533
534pub fn get_bool(args: &Map<String, Value>, key: &str) -> Option<bool> {
535    args.get(key).and_then(serde_json::Value::as_bool)
536}
537
538pub fn get_f64(args: &Map<String, Value>, key: &str) -> Option<f64> {
539    args.get(key).and_then(serde_json::Value::as_f64)
540}
541
542pub fn get_str_array(args: &Map<String, Value>, key: &str) -> Option<Vec<String>> {
543    args.get(key).and_then(|v| v.as_array()).map(|arr| {
544        arr.iter()
545            .filter_map(|v| v.as_str().map(String::from))
546            .collect()
547    })
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553    use serde_json::json;
554
555    fn empty_ctx() -> ToolContext {
556        ToolContext::default()
557    }
558
559    #[test]
560    fn require_resolved_path_returns_resolved() {
561        let mut ctx = empty_ctx();
562        ctx.resolved_paths
563            .insert("path".to_string(), "/abs/file.rs".to_string());
564        let args: Map<String, Value> = Map::new();
565        let result = require_resolved_path(&ctx, &args, "path");
566        assert_eq!(result.unwrap(), "/abs/file.rs");
567    }
568
569    #[test]
570    fn require_resolved_path_surfaces_jail_error() {
571        let mut ctx = empty_ctx();
572        ctx.path_errors.insert(
573            "path".to_string(),
574            "path escapes project root /project".to_string(),
575        );
576        let args: Map<String, Value> = Map::new();
577        let result = require_resolved_path(&ctx, &args, "path");
578        assert!(result.is_err());
579        let err = result.unwrap_err();
580        let msg = format!("{err:?}");
581        assert!(msg.contains("escapes project root"), "got: {msg}");
582    }
583
584    #[test]
585    fn require_resolved_path_detects_non_string() {
586        let ctx = empty_ctx();
587        let mut args: Map<String, Value> = Map::new();
588        args.insert("path".to_string(), json!(42));
589        let result = require_resolved_path(&ctx, &args, "path");
590        assert!(result.is_err());
591        let err = result.unwrap_err();
592        let msg = format!("{err:?}");
593        assert!(msg.contains("must be a string, got number"), "got: {msg}");
594    }
595
596    #[test]
597    fn require_resolved_path_missing_param() {
598        let ctx = empty_ctx();
599        let args: Map<String, Value> = Map::new();
600        let result = require_resolved_path(&ctx, &args, "path");
601        assert!(result.is_err());
602        let err = result.unwrap_err();
603        let msg = format!("{err:?}");
604        assert!(msg.contains("path is required"), "got: {msg}");
605    }
606
607    #[test]
608    fn get_int_coerces_string_to_number() {
609        let mut args = Map::new();
610        args.insert("n".into(), json!("42"));
611        assert_eq!(super::get_int(&args, "n"), Some(42));
612    }
613
614    #[test]
615    fn get_int_native_number_still_works() {
616        let mut args = Map::new();
617        args.insert("n".into(), json!(7));
618        assert_eq!(super::get_int(&args, "n"), Some(7));
619    }
620
621    #[test]
622    fn get_int_invalid_string_returns_none() {
623        let mut args = Map::new();
624        args.insert("n".into(), json!("not_a_number"));
625        assert_eq!(super::get_int(&args, "n"), None);
626    }
627
628    #[test]
629    fn get_usize_coerces_string() {
630        let mut args = Map::new();
631        args.insert("n".into(), json!("100"));
632        assert_eq!(super::get_usize(&args, "n"), Some(100));
633    }
634
635    #[test]
636    fn get_usize_negative_string_returns_none() {
637        let mut args = Map::new();
638        args.insert("n".into(), json!("-5"));
639        assert_eq!(super::get_usize(&args, "n"), None);
640    }
641}