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    /// MCP tool definition including JSON schema. This replaces the
281    /// corresponding entry in `granular_tool_defs()`.
282    fn tool_def(&self) -> Tool;
283
284    /// Execute the tool. Args are the raw JSON-RPC arguments.
285    /// `ctx` provides access to resolved paths and project state.
286    fn handle(&self, args: &Map<String, Value>, ctx: &ToolContext)
287    -> Result<ToolOutput, ErrorData>;
288
289    /// Whether *this* invocation yields a machine-readable payload (e.g. JSON)
290    /// that must reach the client byte-exact and parseable. When `true`, the
291    /// dispatch pipeline suppresses every human-oriented decoration
292    /// (auto-context briefing, verify footer, hints, checkpoints, deprecation
293    /// notices) and terse compression for this call, returning the pure body.
294    /// Keyed on `args` because most tools emit machine-readable output only for
295    /// an opt-in format flag (e.g. `format=json`); defaults to `false` (#990).
296    fn produces_machine_readable(&self, _args: Option<&Map<String, Value>>) -> bool {
297        false
298    }
299
300    /// Attestation that *this* invocation derives its output from the caller's
301    /// arguments and lean-ctx's own state only — it reads no project source,
302    /// provider or stored copy of one. Replay admission relies on it: an
303    /// output with no captured source set is replayed under protection only
304    /// when its tool attests this. The default is `false`: "no source was
305    /// observed" is not proof, an uninstrumented reader publishes none either.
306    /// Override only for a tool whose handler was read for exactly this.
307    fn attests_source_free(&self, _args: Option<&Map<String, Value>>) -> bool {
308        false
309    }
310
311    /// Declaration that *this* invocation reads every project source it
312    /// derives output from through the admitted readers
313    /// (`tools::ctx_read::read_file_*`), on the handler's own thread or on
314    /// workers started with `TaskSpine::spawn_thread` — so that each of them is
315    /// published into the call's source capture. Only then is a captured
316    /// source set the WHOLE set, and only then may it authorize reuse of the
317    /// result (idempotent replay, response cache, source-bound recovery copy).
318    ///
319    /// The default is `false`: a source set captured from a tool that does not
320    /// declare this may be partial (a raw `fs::read`, a thread without the
321    /// capture, a cached body served without a read) and counts as unknown.
322    /// Override only after reading every content path of the handler.
323    fn publishes_every_source(&self, _args: Option<&Map<String, Value>>) -> bool {
324        false
325    }
326}
327
328tokio::task_local! {
329    /// #1781: the cancellation token of the request currently being served,
330    /// scoped by `call_tool` around the whole guarded dispatch.
331    ///
332    /// A task-local rather than a field on the shared server: rmcp spawns one
333    /// task per request and hands each its own `child_token`, so concurrent
334    /// calls would otherwise overwrite one another's token — and cancelling the
335    /// wrong command is worse than not cancelling at all.
336    pub static REQUEST_CT: tokio_util::sync::CancellationToken;
337}
338
339/// Context passed to tool handlers. Contains pre-resolved values that
340/// many tools need, avoiding repeated async lock acquisition inside
341/// handlers. Extended with shared server state for tools that need
342/// cache/session access.
343///
344/// `Clone` exists for per-op delegation (#1088): a batch op with its own
345/// `path` clones the ctx and swaps in that op's resolved path.
346#[derive(Clone)]
347pub struct ToolContext {
348    pub project_root: String,
349    /// Session-scoped trusted roots (MCP `roots/list`, config `extra_roots`),
350    /// snapshotted from the session so sync handlers can honor them without an
351    /// async lock. Empty = single-root jail behaviour (#403).
352    pub extra_roots: Vec<String>,
353    pub minimal: bool,
354    /// Pre-resolved paths keyed by argument name (e.g. "path" -> "/abs/dir").
355    pub resolved_paths: std::collections::HashMap<String, String>,
356    /// CRP mode for compression-aware tools.
357    pub crp_mode: crate::tools::CrpMode,
358    /// Shared cache handle for tools that need read/write access.
359    pub cache: Option<crate::tools::SharedCache>,
360    /// Shared session handle for tools that need session access.
361    pub session: Option<std::sync::Arc<tokio::sync::RwLock<lean_ctx::core::session::SessionState>>>,
362    /// Tool call records for session-aware tools (e.g. ctx_session status).
363    pub tool_calls:
364        Option<std::sync::Arc<tokio::sync::RwLock<Vec<lean_ctx::core::protocol::ToolCallRecord>>>>,
365    /// Current agent identity for multi-agent tools.
366    pub agent_id: Option<std::sync::Arc<tokio::sync::RwLock<Option<String>>>>,
367    /// Active workflow run state.
368    pub workflow:
369        Option<std::sync::Arc<tokio::sync::RwLock<Option<lean_ctx::core::workflow::WorkflowRun>>>>,
370    /// Context ledger for handoff operations.
371    pub ledger:
372        Option<std::sync::Arc<tokio::sync::RwLock<lean_ctx::core::context_ledger::ContextLedger>>>,
373    /// Client name (cursor, claude, etc.).
374    pub client_name: Option<std::sync::Arc<tokio::sync::RwLock<String>>>,
375    /// Optional MCP client role supplied by the session/request context.
376    /// Absent preserves the backward-compatible permissive default.
377    pub client_role: Option<String>,
378    /// Optional shell permission supplied by the session/request context.
379    /// `None` preserves the backward-compatible permissive default.
380    pub shell_access: Option<bool>,
381    /// Pipeline stats for metrics/proof tools.
382    pub pipeline_stats:
383        Option<std::sync::Arc<tokio::sync::RwLock<lean_ctx::core::pipeline::PipelineStats>>>,
384    /// Global call counter for context tools.
385    pub call_count: Option<std::sync::Arc<std::sync::atomic::AtomicUsize>>,
386    /// Autonomy state for search repeat detection.
387    pub autonomy: Option<std::sync::Arc<lean_ctx::core::autonomy::AutonomyState>>,
388    /// Errors from path resolution (PathJail rejection, secret path, etc.).
389    /// Keyed by argument name (e.g. "path" -> "path escapes project root: ...").
390    pub path_errors: std::collections::HashMap<String, String>,
391    /// Shared in-memory BM25 index cache for semantic search.
392    pub bm25_cache: Option<lean_ctx::core::bm25_cache::SharedBm25Cache>,
393    /// MCP progress notification sender for long-running operations.
394    pub progress_sender: Option<crate::server::progress::SharedProgressSender>,
395    /// Cancellation token for this request, when the transport supplies one
396    /// (#1781).
397    ///
398    /// A synchronous tool that waits on a child process cannot observe an
399    /// aborted call without it: the host stops waiting, but the wait loop keeps
400    /// running and holds its thread until its own cap expires. Tools that block
401    /// should consult this and return promptly when it fires.
402    pub cancel: Option<tokio_util::sync::CancellationToken>,
403}
404
405impl Default for ToolContext {
406    /// Minimal context: `project_root` empty, all shared-state handles `None`.
407    /// Single source for the one-shot CLI ctx (`cli::call_cmd::oneshot_ctx`) and
408    /// the `empty_ctx` test helper — adding a field no longer breaks either.
409    fn default() -> Self {
410        Self {
411            project_root: String::new(),
412            extra_roots: Vec::new(),
413            minimal: false,
414            resolved_paths: std::collections::HashMap::new(),
415            crp_mode: crate::tools::CrpMode::Off,
416            cache: None,
417            session: None,
418            tool_calls: None,
419            agent_id: None,
420            workflow: None,
421            ledger: None,
422            client_name: None,
423            client_role: None,
424            shell_access: None,
425            pipeline_stats: None,
426            call_count: None,
427            autonomy: None,
428            path_errors: std::collections::HashMap::new(),
429            bm25_cache: None,
430            progress_sender: None,
431            cancel: None,
432        }
433    }
434}
435
436impl ToolContext {
437    pub fn resolved_path(&self, arg: &str) -> Option<&str> {
438        self.resolved_paths.get(arg).map(String::as_str)
439    }
440
441    /// Returns the path resolution error for a given key, if any.
442    pub fn path_error(&self, key: &str) -> Option<&str> {
443        self.path_errors.get(key).map(String::as_str)
444    }
445
446    /// Sync path resolution using `project_root` + session `extra_roots`. Thin
447    /// wrapper over [`lean_ctx::core::path_resolve::resolve_tool_path_with_roots`]
448    /// for sync tool handlers.
449    pub fn resolve_path_sync(&self, path: &str) -> Result<String, String> {
450        lean_ctx::core::path_resolve::resolve_tool_path_with_roots(
451            Some(&self.project_root),
452            None,
453            path,
454            &self.extra_roots,
455        )
456    }
457
458    /// Default-deny write gate for the read-only tier (#475). Write-capable tool
459    /// handlers must call this with an already-resolved absolute path before
460    /// touching the filesystem; it errors if the path is inside a configured
461    /// `read_only_roots` subtree. A no-op (always `Ok`) when no read-only roots
462    /// are configured, so non-users pay nothing. Thin wrapper over the single
463    /// choke point [`lean_ctx::core::pathjail::enforce_writable`] — the low-level
464    /// atomic writers call the same function, so this is the ergonomic,
465    /// early-error layer, not the only line of defence.
466    pub fn ensure_writable(&self, resolved_path: &str) -> Result<(), String> {
467        lean_ctx::core::pathjail::enforce_writable(std::path::Path::new(resolved_path))
468    }
469}
470
471// ── Arg extraction helpers (mirror server/helpers.rs for standalone use) ──
472
473/// Extract a resolved path from context with differentiated error messages.
474/// Returns descriptive errors for: missing param, PathJail rejection, wrong type.
475pub fn require_resolved_path(
476    ctx: &ToolContext,
477    args: &Map<String, Value>,
478    key: &str,
479) -> Result<String, ErrorData> {
480    if let Some(path) = ctx.resolved_path(key) {
481        return Ok(path.to_string());
482    }
483    if let Some(err) = ctx.path_error(key) {
484        return Err(ErrorData::invalid_params(format!("{key}: {err}"), None));
485    }
486    if let Some(val) = args.get(key)
487        && !val.is_string()
488    {
489        let type_name = match val {
490            Value::Number(_) => "number",
491            Value::Bool(_) => "boolean",
492            Value::Array(_) => "array",
493            Value::Object(_) => "object",
494            Value::Null => "null",
495            Value::String(_) => unreachable!(),
496        };
497        return Err(ErrorData::invalid_params(
498            format!("{key} must be a string, got {type_name}"),
499            None,
500        ));
501    }
502    Err(ErrorData::invalid_params(
503        format!("{key} is required"),
504        None,
505    ))
506}
507
508pub fn get_str(args: &Map<String, Value>, key: &str) -> Option<String> {
509    args.get(key).and_then(|v| v.as_str()).map(String::from)
510}
511
512pub fn get_int(args: &Map<String, Value>, key: &str) -> Option<i64> {
513    args.get(key)
514        .and_then(|v| v.as_i64().or_else(|| v.as_str()?.parse().ok()))
515}
516
517/// Read a non-negative integer argument as `usize`.
518///
519/// Returns `None` for missing or negative values. This avoids the
520/// `negative_i64 as usize` wrap to `usize::MAX`, which previously let an agent
521/// trigger unbounded allocations (e.g. `top_k`, `limit`, `max_results`) → OOM.
522/// Callers should still apply a sensible upper cap on the result.
523pub fn get_usize(args: &Map<String, Value>, key: &str) -> Option<usize> {
524    get_int(args, key).and_then(|n| usize::try_from(n).ok())
525}
526
527pub fn get_bool(args: &Map<String, Value>, key: &str) -> Option<bool> {
528    args.get(key).and_then(serde_json::Value::as_bool)
529}
530
531pub fn get_f64(args: &Map<String, Value>, key: &str) -> Option<f64> {
532    args.get(key).and_then(serde_json::Value::as_f64)
533}
534
535pub fn get_str_array(args: &Map<String, Value>, key: &str) -> Option<Vec<String>> {
536    args.get(key).and_then(|v| v.as_array()).map(|arr| {
537        arr.iter()
538            .filter_map(|v| v.as_str().map(String::from))
539            .collect()
540    })
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546    use serde_json::json;
547
548    fn empty_ctx() -> ToolContext {
549        ToolContext::default()
550    }
551
552    #[test]
553    fn require_resolved_path_returns_resolved() {
554        let mut ctx = empty_ctx();
555        ctx.resolved_paths
556            .insert("path".to_string(), "/abs/file.rs".to_string());
557        let args: Map<String, Value> = Map::new();
558        let result = require_resolved_path(&ctx, &args, "path");
559        assert_eq!(result.unwrap(), "/abs/file.rs");
560    }
561
562    #[test]
563    fn require_resolved_path_surfaces_jail_error() {
564        let mut ctx = empty_ctx();
565        ctx.path_errors.insert(
566            "path".to_string(),
567            "path escapes project root /project".to_string(),
568        );
569        let args: Map<String, Value> = Map::new();
570        let result = require_resolved_path(&ctx, &args, "path");
571        assert!(result.is_err());
572        let err = result.unwrap_err();
573        let msg = format!("{err:?}");
574        assert!(msg.contains("escapes project root"), "got: {msg}");
575    }
576
577    #[test]
578    fn require_resolved_path_detects_non_string() {
579        let ctx = empty_ctx();
580        let mut args: Map<String, Value> = Map::new();
581        args.insert("path".to_string(), json!(42));
582        let result = require_resolved_path(&ctx, &args, "path");
583        assert!(result.is_err());
584        let err = result.unwrap_err();
585        let msg = format!("{err:?}");
586        assert!(msg.contains("must be a string, got number"), "got: {msg}");
587    }
588
589    #[test]
590    fn require_resolved_path_missing_param() {
591        let ctx = empty_ctx();
592        let args: Map<String, Value> = Map::new();
593        let result = require_resolved_path(&ctx, &args, "path");
594        assert!(result.is_err());
595        let err = result.unwrap_err();
596        let msg = format!("{err:?}");
597        assert!(msg.contains("path is required"), "got: {msg}");
598    }
599
600    #[test]
601    fn get_int_coerces_string_to_number() {
602        let mut args = Map::new();
603        args.insert("n".into(), json!("42"));
604        assert_eq!(super::get_int(&args, "n"), Some(42));
605    }
606
607    #[test]
608    fn get_int_native_number_still_works() {
609        let mut args = Map::new();
610        args.insert("n".into(), json!(7));
611        assert_eq!(super::get_int(&args, "n"), Some(7));
612    }
613
614    #[test]
615    fn get_int_invalid_string_returns_none() {
616        let mut args = Map::new();
617        args.insert("n".into(), json!("not_a_number"));
618        assert_eq!(super::get_int(&args, "n"), None);
619    }
620
621    #[test]
622    fn get_usize_coerces_string() {
623        let mut args = Map::new();
624        args.insert("n".into(), json!("100"));
625        assert_eq!(super::get_usize(&args, "n"), Some(100));
626    }
627
628    #[test]
629    fn get_usize_negative_string_returns_none() {
630        let mut args = Map::new();
631        args.insert("n".into(), json!("-5"));
632        assert_eq!(super::get_usize(&args, "n"), None);
633    }
634}