1mod output;
10mod runtime;
11
12use std::path::PathBuf;
13use std::sync::Arc;
14use std::time::{Duration, Instant};
15
16use serde_json::json;
17use tokio_util::sync::CancellationToken;
18
19use lash_core::plugin::{
20 PluginError, PluginFactory, PluginSessionContext, PluginSpec, PluginSpecFactory, SessionPlugin,
21};
22use lash_core::runtime::ProcessEventSemanticsSpec;
23use lash_core::{
24 PreparedToolCall, ProcessEventType, ProcessHandleDescriptor, ProcessInput, ProcessStartRequest,
25 ProgressSender, PromptContribution, SessionScope, SessionToolAccess, ToolCall, ToolDefinition,
26 ToolProvider, ToolResult, ToolScheduling,
27};
28
29use lash_tool_support::{
30 StaticToolExecute, StaticToolProvider, ToolDefinitionLashlangExt, object_schema,
31 parse_optional_bool, parse_optional_usize_arg, require_str,
32};
33
34use crate::shell::output::{PollOutcome, shell_io_result, timed_out_shell_io_result};
35use crate::shell::runtime::{
36 CommonCommandParams, DEFAULT_EXEC_COMMAND_TIMEOUT_MS, ExecCommandParams,
37 PipeExecProcessRequest, ShellRuntime, StartCommandParams, WaitBehavior,
38};
39
40const SHELL_STDIN_SIGNAL: &str = "stdin";
41const SHELL_STDIN_SIGNAL_EVENT: &str = "signal.stdin";
42
43pub fn shell_prompt_contributions() -> Vec<PromptContribution> {
44 shell_prompt_contributions_for_access(&SessionToolAccess::default())
45}
46
47pub fn shell_prompt_contributions_for_access(
51 access: &SessionToolAccess,
52) -> Vec<PromptContribution> {
53 let mut command_execution = String::from(
54 "Use `shell.exec` for one-shot commands; it returns only after the process exits and successful results include `status: \"completed\"`, `done: true`, and `exit_code`. Use `shell.start` only for interactive or intentionally long-lived processes; it returns a process handle that is visible to `processes.list` and cancellable with `processes.cancel`.",
55 );
56 if tool_callable_from_authority(access, "write_stdin") {
57 command_execution.push_str(" Send stdin to running shell processes with `shell.write`.");
58 }
59 command_execution.push_str(
60 " For builds, installs, tests, migrations, service setup, and verification commands, use `shell.exec` and wait for completion before concluding.",
61 );
62 vec![
63 PromptContribution::guidance("Command Execution", command_execution),
64 PromptContribution::guidance(
65 "Git Safety",
66 "Avoid destructive git commands unless explicitly requested.",
67 ),
68 ]
69}
70
71fn tool_callable_from_authority(access: &SessionToolAccess, name: &str) -> bool {
72 if access.hides(name) {
73 return false;
74 }
75 access.tools.is_empty() || access.tools.iter().any(|tool| tool.name() == name)
76}
77
78pub struct StandardShell {
79 runtime: ShellRuntime,
80}
81
82impl StandardShell {
83 pub fn new() -> Self {
84 Self {
85 runtime: ShellRuntime::new(),
86 }
87 }
88
89 pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
90 self.runtime = self.runtime.with_cwd(cwd);
91 self
92 }
93
94 fn parse_common_command_params(
95 &self,
96 args: &serde_json::Value,
97 ) -> Result<CommonCommandParams, ToolResult> {
98 let cmd = require_str(args, "cmd")?.to_string();
99 let workdir = self.runtime.resolve_workdir(
100 args.get("workdir")
101 .and_then(|value| value.as_str())
102 .filter(|value| !value.is_empty()),
103 );
104 let shell_path = args
105 .get("shell")
106 .and_then(|value| value.as_str())
107 .filter(|value| !value.is_empty())
108 .unwrap_or(&self.runtime.shell_path)
109 .to_string();
110 let login = parse_optional_bool(args, "login", false)?;
111 let allow_nonzero_exit = parse_optional_bool(args, "allow_nonzero_exit", false)?;
112 let max_output_tokens = parse_optional_usize_arg(args, "max_output_tokens", None, true, 1)?;
113
114 Ok(CommonCommandParams {
115 cmd,
116 workdir,
117 shell_path,
118 login,
119 allow_nonzero_exit,
120 max_output_tokens,
121 })
122 }
123
124 fn parse_exec_command_params(
125 &self,
126 args: &serde_json::Value,
127 ) -> Result<ExecCommandParams, ToolResult> {
128 let common = self.parse_common_command_params(args)?;
129 let timeout_ms = parse_optional_usize_arg(args, "timeout_ms", None, false, 1)?
130 .map(|value| value as u64)
131 .unwrap_or(DEFAULT_EXEC_COMMAND_TIMEOUT_MS);
132
133 Ok(ExecCommandParams {
134 cmd: common.cmd,
135 workdir: common.workdir,
136 shell_path: common.shell_path,
137 login: common.login,
138 allow_nonzero_exit: common.allow_nonzero_exit,
139 timeout_ms,
140 max_output_tokens: common.max_output_tokens,
141 })
142 }
143
144 fn parse_start_command_params(
145 &self,
146 args: &serde_json::Value,
147 ) -> Result<StartCommandParams, ToolResult> {
148 let common = self.parse_common_command_params(args)?;
149
150 Ok(StartCommandParams {
151 cmd: common.cmd,
152 workdir: common.workdir,
153 shell_path: common.shell_path,
154 login: common.login,
155 allow_nonzero_exit: common.allow_nonzero_exit,
156 max_output_tokens: common.max_output_tokens,
157 })
158 }
159
160 async fn exec_command(
161 &self,
162 params: &ExecCommandParams,
163 progress: Option<&ProgressSender>,
164 cancel: Option<CancellationToken>,
165 ) -> ToolResult {
166 let started = Instant::now();
167 let handle_id = self.runtime.allocate_handle_id();
168
169 match self
170 .runtime
171 .exec_pipe_process(PipeExecProcessRequest {
172 id: &handle_id,
173 command: ¶ms.cmd,
174 workdir: ¶ms.workdir,
175 login: params.login,
176 shell_path: ¶ms.shell_path,
177 timeout: Some(Duration::from_millis(params.timeout_ms)),
178 progress,
179 max_output_tokens: params.max_output_tokens,
180 cancel,
181 })
182 .await
183 {
184 Ok(PollOutcome::Running {
185 output,
186 original_token_count,
187 full_output_path,
188 ..
189 }) => timed_out_shell_io_result(
190 &handle_id,
191 output,
192 original_token_count,
193 full_output_path.as_deref(),
194 started.elapsed().as_secs_f64(),
195 params.timeout_ms,
196 params.allow_nonzero_exit,
197 ),
198 Ok(PollOutcome::Exited {
199 output,
200 original_token_count,
201 exit_code,
202 full_output_path,
203 }) => shell_io_result(
204 &handle_id,
205 output,
206 Some(exit_code),
207 original_token_count,
208 full_output_path.as_deref(),
209 started.elapsed().as_secs_f64(),
210 params.allow_nonzero_exit,
211 ),
212 Ok(PollOutcome::Cancelled) => ToolResult::cancelled("tool call cancelled"),
213 Err(err) => ToolResult::err(json!(err)),
214 }
215 }
216
217 async fn start_command(
218 &self,
219 params: &StartCommandParams,
220 context: &lash_core::ToolContext<'_>,
221 progress: Option<&ProgressSender>,
222 cancel: Option<CancellationToken>,
223 ) -> ToolResult {
224 if let Some(process_id) = context.async_process_id() {
225 return self
226 .run_start_command_process(process_id, params, context, progress, cancel)
227 .await;
228 }
229 self.register_start_command_process(params, context).await
230 }
231
232 async fn register_start_command_process(
233 &self,
234 params: &StartCommandParams,
235 context: &lash_core::ToolContext<'_>,
236 ) -> ToolResult {
237 let process_id = context
238 .tool_call_id()
239 .filter(|id| !id.is_empty())
240 .map(str::to_string)
241 .unwrap_or_else(|| format!("shell:{}", self.runtime.allocate_handle_id()));
242 let args = start_command_process_args(params);
243 let call = PreparedToolCall::from_parts(
244 process_id.clone(),
245 "tool:start_command",
246 "start_command",
247 args,
248 None,
249 serde_json::Value::Null,
250 );
251 let descriptor = ProcessHandleDescriptor::new(Some("shell"), Some(params.cmd.clone()));
252 let request = ProcessStartRequest::new(
253 process_id.clone(),
254 ProcessInput::ToolCall { call },
255 lash_core::ProcessOriginator::host(),
256 )
257 .with_grant(Some(lash_core::ProcessStartGrant {
258 session_scope: SessionScope::new("request-descriptor"),
259 descriptor,
260 }))
261 .with_extra_event_types([shell_signal_event_type()]);
262 match context.processes().start(request).await {
263 Ok(summary) => {
264 let mut handle = serde_json::to_value(summary).unwrap_or_else(|_| {
265 lash_core::RuntimeExecutionContext::process_handle_json(&process_id)
266 });
267 if let Some(object) = handle.as_object_mut() {
268 object.insert("status".to_string(), json!("running"));
269 object.insert("done".to_string(), json!(false));
270 object.insert("running".to_string(), json!(true));
271 }
272 ToolResult::ok(handle)
273 }
274 Err(err) => ToolResult::err_fmt(err.to_string()),
275 }
276 }
277
278 async fn run_start_command_process(
279 &self,
280 process_id: &str,
281 params: &StartCommandParams,
282 context: &lash_core::ToolContext<'_>,
283 progress: Option<&ProgressSender>,
284 cancel: Option<CancellationToken>,
285 ) -> ToolResult {
286 let started = Instant::now();
287 let handle_id = process_id.to_string();
288
289 if let Err(err) = self.runtime.spawn_process(
290 handle_id.clone(),
291 ¶ms.cmd,
292 ¶ms.workdir,
293 params.login,
294 ¶ms.shell_path,
295 ) {
296 return ToolResult::err(json!(err));
297 }
298
299 let signal_done = CancellationToken::new();
300 let signal_forwarder =
301 self.spawn_stdin_signal_forwarder(handle_id.clone(), context, signal_done.clone());
302 match self
303 .runtime
304 .wait_until_exit_or_timeout(
305 &handle_id,
306 None,
307 progress,
308 params.max_output_tokens,
309 WaitBehavior { baseline_len: 0 },
310 cancel,
311 )
312 .await
313 {
314 Ok(PollOutcome::Running { .. }) => {
315 signal_done.cancel();
316 let _ = signal_forwarder.await;
317 self.runtime.remove_process(&handle_id);
318 ToolResult::err_fmt("background shell process returned running without a timeout")
319 }
320 Ok(PollOutcome::Exited {
321 output,
322 original_token_count,
323 exit_code,
324 full_output_path,
325 }) => {
326 signal_done.cancel();
327 let _ = signal_forwarder.await;
328 self.runtime.remove_process(&handle_id);
329 shell_io_result(
330 &handle_id,
331 output,
332 Some(exit_code),
333 original_token_count,
334 full_output_path.as_deref(),
335 started.elapsed().as_secs_f64(),
336 params.allow_nonzero_exit,
337 )
338 }
339 Ok(PollOutcome::Cancelled) => {
340 signal_done.cancel();
341 let _ = signal_forwarder.await;
342 self.runtime.remove_process(&handle_id);
343 ToolResult::cancelled("tool call cancelled")
344 }
345 Err(err) => {
346 signal_done.cancel();
347 let _ = signal_forwarder.await;
348 self.runtime.remove_process(&handle_id);
349 ToolResult::err(json!(err))
350 }
351 }
352 }
353
354 fn spawn_stdin_signal_forwarder(
355 &self,
356 process_id: String,
357 context: &lash_core::ToolContext<'_>,
358 done: CancellationToken,
359 ) -> tokio::task::JoinHandle<()> {
360 let runtime = self.runtime.clone();
361 let events = context.process_events();
362 tokio::spawn(async move {
363 let mut after_sequence = 0;
364 loop {
365 let event = tokio::select! {
366 _ = done.cancelled() => break,
367 event = events.wait_event_after(SHELL_STDIN_SIGNAL_EVENT, after_sequence) => event,
368 };
369 let Ok(event) = event else {
370 break;
371 };
372 after_sequence = event.sequence;
373 if let Some(chars) = event.payload.get("chars").and_then(|value| value.as_str()) {
374 let _ = runtime.write_stdin(&process_id, chars).await;
375 }
376 if event
377 .payload
378 .get("close_stdin")
379 .and_then(|value| value.as_bool())
380 .unwrap_or(false)
381 {
382 let _ = runtime.close_stdin(&process_id).await;
383 }
384 }
385 })
386 }
387
388 async fn write_stdin_call(
389 &self,
390 args: &serde_json::Value,
391 context: &lash_core::ToolContext<'_>,
392 ) -> ToolResult {
393 let process_id = match parse_process_id(args) {
394 Ok(value) => value,
395 Err(err) => return err,
396 };
397 let chars = args
398 .get("chars")
399 .and_then(|value| value.as_str())
400 .unwrap_or("");
401 let close_stdin = match parse_optional_bool(args, "close_stdin", false) {
402 Ok(value) => value,
403 Err(err) => return err,
404 };
405 match context
406 .processes()
407 .signal(
408 &process_id,
409 SHELL_STDIN_SIGNAL,
410 json!({
411 "chars": chars,
412 "close_stdin": close_stdin,
413 }),
414 )
415 .await
416 {
417 Ok(event) => ToolResult::ok(json!({
418 "process_id": process_id,
419 "status": "signalled",
420 "sequence": event.sequence,
421 })),
422 Err(err) => ToolResult::err_fmt(err.to_string()),
423 }
424 }
425}
426
427fn start_command_process_args(params: &StartCommandParams) -> serde_json::Value {
428 let mut args = serde_json::Map::new();
429 args.insert("cmd".to_string(), json!(params.cmd.clone()));
430 args.insert(
431 "workdir".to_string(),
432 json!(params.workdir.to_string_lossy().to_string()),
433 );
434 args.insert("shell".to_string(), json!(params.shell_path.clone()));
435 args.insert("login".to_string(), json!(params.login));
436 args.insert(
437 "allow_nonzero_exit".to_string(),
438 json!(params.allow_nonzero_exit),
439 );
440 if let Some(max_output_tokens) = params.max_output_tokens {
441 args.insert("max_output_tokens".to_string(), json!(max_output_tokens));
442 }
443 serde_json::Value::Object(args)
444}
445
446fn shell_signal_event_type() -> ProcessEventType {
447 ProcessEventType {
448 name: SHELL_STDIN_SIGNAL_EVENT.to_string(),
449 payload_schema: lash_core::LashSchema::any(),
450 semantics: ProcessEventSemanticsSpec::default(),
451 }
452}
453
454impl Default for StandardShell {
455 fn default() -> Self {
456 Self::new()
457 }
458}
459
460pub fn shell_provider(shell: StandardShell) -> StaticToolProvider<StandardShell> {
462 let definitions = shell.tool_definitions();
463 StaticToolProvider::new(definitions, shell)
464}
465
466#[async_trait::async_trait]
467impl StaticToolExecute for StandardShell {
468 async fn execute(&self, call: ToolCall<'_>) -> ToolResult {
469 let cancellation_token = call.context.cancellation_token().cloned();
470 self.dispatch(
471 call.name,
472 call.args,
473 call.context,
474 call.progress,
475 cancellation_token,
476 )
477 .await
478 }
479}
480
481impl StandardShell {
482 fn tool_definitions(&self) -> Vec<ToolDefinition> {
483 let exec_command_description = "Run a noninteractive one-shot command with stdin closed and stdout/stderr captured, then wait for it to finish. Successful results always include `status: \"completed\"`, `done: true`, `running: false`, cleaned `output`, and `exit_code`. Commands time out after 600000 ms by default; set `timeout_ms` to override the hard timeout. Timed-out commands are killed and the result has `status: \"timed_out\"`, `timed_out: true`, and no `exit_code`; by default this fails the tool. Use `shell.start` instead for interactive, TTY-dependent, or intentionally long-lived processes. Nonzero exit codes (including SIGPIPE 141 from `cmd | head`-style pipelines) fail the tool by default. Pass `allow_nonzero_exit: true` to receive the result without failure on either nonzero exit or timeout, then inspect `exit_code` and `timed_out`. ANSI/control noise is stripped from returned output. Large or truncated output may also include `full_output_path` pointing at the saved raw stream.";
484 let start_command_description = "Start an interactive or intentionally long-lived command in a PTY as a durable background process. The result is a process handle with `__handle__: \"process\"`, `id`, `process_id`, `status: \"running\"`, `done: false`, and `running: true`; use `processes.list` to see it and `processes.cancel` to stop it. Nonzero exit codes fail the eventual process output by default; pass `allow_nonzero_exit: true` only when nonzero is expected data. Use `shell.exec` for builds, installs, tests, service setup, verification, and other commands that must complete before the next step.";
485 let command_common = |command_description: &str| {
486 json!({
487 "cmd": {
488 "type": "string",
489 "description": command_description
490 },
491 "workdir": {
492 "type": "string",
493 "description": "Optional working directory to run the command in; defaults to the turn cwd."
494 },
495 "shell": {
496 "type": "string",
497 "description": "Shell binary to launch. Defaults to the user's default shell."
498 },
499 "login": {
500 "type": "boolean",
501 "default": false,
502 "description": "Whether to run the shell with -l semantics. Defaults to false to avoid startup prompts and shell init noise."
503 },
504 "allow_nonzero_exit": {
505 "type": "boolean",
506 "default": false,
507 "description": "Shell-only flag. When true, nonzero exit codes are returned as successful tool results instead of failed tool calls; inspect `exit_code` yourself. Defaults to false."
508 },
509 "max_output_tokens": {
510 "type": "integer",
511 "minimum": 1,
512 "description": "Maximum number of tokens to return. Excess output will be truncated."
513 }
514 })
515 };
516 vec![
517 ToolDefinition::raw(
518 "tool:exec_command",
519 "exec_command",
520 exec_command_description,
521 {
522 let mut properties = command_common("Shell command to execute.");
523 properties["timeout_ms"] = json!({
524 "type": "integer",
525 "minimum": 1,
526 "default": DEFAULT_EXEC_COMMAND_TIMEOUT_MS,
527 "description": "Hard timeout in milliseconds. If reached before the command exits, the process is killed and the result has `status: \"timed_out\"` and `timed_out: true`. By default this fails the tool; pass `allow_nonzero_exit: true` to receive the timed-out result without failure. Defaults to 600000 ms."
528 });
529 object_schema(properties, &["cmd"])
530 },
531 shell_exec_output_schema(),
532 )
533 .with_examples(vec![
534 r#"await shell.exec({ cmd: "cargo test -p lash-protocol-rlm", timeout_ms: 600000 })?"#.into(),
535 r#"await shell.exec({ cmd: "test -f Cargo.lock", allow_nonzero_exit: true })?"#.into(),
536 ])
537 .with_lashlang_binding(lash_tool_support::lashlang_binding(
538 ["shell"],
539 "exec",
540 &["shell", "bash"],
541 ))
542 .with_scheduling(ToolScheduling::Serial),
543 ToolDefinition::raw(
544 "tool:start_command",
545 "start_command",
546 start_command_description,
547 object_schema(command_common("Shell command to start."), &["cmd"]),
548 shell_start_output_schema(),
549 )
550 .with_examples(vec![
551 r#"await shell.start({ cmd: "python -m http.server 8000" })?"#.into(),
552 ])
553 .with_lashlang_binding(lash_tool_support::lashlang_binding(
554 ["shell"],
555 "start",
556 &["long_running_command", "pty"],
557 ))
558 .with_scheduling(ToolScheduling::Serial),
559 ToolDefinition::raw(
560 "tool:write_stdin",
561 "write_stdin",
562 "Send bytes to stdin for a running shell process started by `shell.start`. Use `close_stdin: true` to send EOF. This only acknowledges delivery of the signal; use process lifecycle tools to inspect or cancel the background process.",
563 object_schema(
564 json!({
565 "process_id": {
566 "type": "string",
567 "description": "Process id returned by `shell.start`."
568 },
569 "chars": {
570 "type": "string",
571 "default": "",
572 "description": "Bytes to write to stdin; may be empty when only closing stdin."
573 },
574 "close_stdin": {
575 "type": "boolean",
576 "default": false,
577 "description": "Close stdin after writing to send EOF to the process."
578 }
579 }),
580 &["process_id"],
581 ),
582 shell_write_output_schema(),
583 )
584 .with_examples(vec![
585 r#"await shell.write({ process_id: "call-shell-1", chars: "status\n" })?"#.into(),
586 r#"await shell.write({ process_id: "call-shell-1", chars: "", close_stdin: true })?"#.into(),
587 ])
588 .with_lashlang_binding(lash_tool_support::lashlang_binding(
589 ["shell"],
590 "write",
591 &["send_stdin", "poll_command"],
592 ))
593 .with_scheduling(ToolScheduling::Serial),
594 ]
595 }
596
597 async fn dispatch(
598 &self,
599 name: &str,
600 args: &serde_json::Value,
601 context: &lash_core::ToolContext<'_>,
602 progress: Option<&ProgressSender>,
603 cancel: Option<CancellationToken>,
604 ) -> ToolResult {
605 match name {
606 "exec_command" => {
607 let params = match self.parse_exec_command_params(args) {
608 Ok(params) => params,
609 Err(err) => return err,
610 };
611 self.exec_command(¶ms, progress, cancel).await
612 }
613 "start_command" => {
614 let params = match self.parse_start_command_params(args) {
615 Ok(params) => params,
616 Err(err) => return err,
617 };
618 self.start_command(¶ms, context, progress, cancel).await
619 }
620 "write_stdin" => self.write_stdin_call(args, context).await,
621 _ => ToolResult::err_fmt(format_args!("Unknown tool: {name}")),
622 }
623 }
624}
625
626fn shell_exec_output_schema() -> serde_json::Value {
627 json!({
628 "type": "object",
629 "properties": {
630 "output": { "type": "string" },
631 "status": { "type": "string", "enum": ["completed", "timed_out"] },
632 "done": { "type": "boolean" },
633 "running": { "type": "boolean" },
634 "wall_time_seconds": { "type": "number", "minimum": 0 },
635 "exit_code": { "type": "integer" },
636 "timed_out": { "type": "boolean" },
637 "error": { "type": "string" },
638 "original_token_count": { "type": "integer", "minimum": 0 },
639 "full_output_path": { "type": "string" }
640 },
641 "required": ["output", "status", "done", "running", "wall_time_seconds"],
642 "additionalProperties": false
643 })
644}
645
646fn shell_start_output_schema() -> serde_json::Value {
647 json!({
648 "type": "object",
649 "properties": {
650 "__handle__": { "type": "string", "enum": ["process"] },
651 "id": { "type": "string" },
652 "process_id": { "type": "string" },
653 "status": { "type": "string", "enum": ["running"] },
654 "done": { "type": "boolean" },
655 "running": { "type": "boolean" }
656 },
657 "required": ["__handle__", "id", "process_id", "status", "done", "running"],
658 "additionalProperties": false
659 })
660}
661
662fn shell_write_output_schema() -> serde_json::Value {
663 json!({
664 "type": "object",
665 "properties": {
666 "process_id": { "type": "string" },
667 "status": { "type": "string", "enum": ["signalled"] },
668 "sequence": { "type": "integer", "minimum": 0 }
669 },
670 "required": ["process_id", "status", "sequence"],
671 "additionalProperties": false
672 })
673}
674
675fn parse_process_id(args: &serde_json::Value) -> Result<String, ToolResult> {
676 require_str(args, "process_id").map(str::to_string)
677}
678
679#[derive(Default)]
685pub struct StandardShellPluginFactory;
686
687impl StandardShellPluginFactory {
688 pub fn new() -> Self {
689 Self
690 }
691}
692
693impl PluginFactory for StandardShellPluginFactory {
694 fn id(&self) -> &'static str {
695 "shell"
696 }
697
698 fn build(&self, ctx: &PluginSessionContext) -> Result<Arc<dyn SessionPlugin>, PluginError> {
699 let tool_access = ctx.tool_access.clone();
700 let provider = Arc::new(shell_provider(StandardShell::new())) as Arc<dyn ToolProvider>;
701 PluginSpecFactory::new(
702 "shell",
703 Arc::new(move |_ctx| {
704 let provider = Arc::clone(&provider);
705 let tool_access = tool_access.clone();
706 Ok(PluginSpec::new()
707 .with_tool_provider(provider)
708 .with_prompt_contributor(Arc::new(move |_ctx| {
709 let tool_access = tool_access.clone();
710 Box::pin(
711 async move { Ok(shell_prompt_contributions_for_access(&tool_access)) },
712 )
713 })))
714 }),
715 )
716 .build(ctx)
717 }
718}
719
720include!("tests.rs");