1use rmcp::ErrorData;
2use rmcp::model::Tool;
3use serde_json::{Map, Value, json};
4
5use crate::server::tool_trait::{
6 McpTool, ShellOutcome, ToolContext, ToolOutput, get_bool, get_int, get_str,
7};
8use crate::tool_defs::tool_def;
9
10pub struct CtxShellTool;
11
12impl McpTool for CtxShellTool {
13 fn name(&self) -> &'static str {
14 "ctx_shell"
15 }
16
17 fn tool_def(&self) -> Tool {
18 tool_def(
19 "ctx_shell",
20 "WORKFLOW: preferred — auto-compresses output (build/test/log).\n\
21 raw=true for verbatim output; inline=true for moderately-sized verbatim output.\n\
22 [exit:N] on errors (lossless).\n\
23 POLICY (by design): allowlisted read-only path; ctx_execute is the trusted script path.\n\
24 A [BLOCKED] command is permanent — escalate to ctx_execute(language=\"shell\"), do not retry here.\n\
25 ANTIPATTERN: multi-line scripts, sh/bash script.sh, $var-as-command → ctx_execute.",
26 json!({
27 "type": "object",
28 "properties": {
29 "command": { "type": "string", "description": "Shell command" },
30 "raw": { "type": "boolean", "description": "Skip compression (verbatim)" },
31 "inline": { "type": "boolean", "description": "Return verbatim output inline up to archive.inline_max_bytes; larger output uses the archive/firewall" },
32 "cwd": { "type": "string", "description": "Working dir (persists across calls)" },
33 "timeout_ms": { "type": "integer", "description": "Job lifetime in ms (max 3600000) — NOT the inline wait. A command still running at the ~110s foreground cap detaches to a pollable shell_* job and keeps running up to timeout_ms. Overridden by LEAN_CTX_SHELL_TIMEOUT_MS." },
34 "env": { "type": "object", "description": "Extra env vars", "additionalProperties": { "type": "string" } },
35 "run_in_background": { "type": "boolean", "description": "Detach immediately and return a job id. The command keeps timeout_ms; poll or cancel with background_action and job_id." },
36 "background_action": { "type": "string", "enum": ["status", "cancel"], "description": "Inspect or cancel a background ctx_shell job." },
37 "job_id": { "type": "string", "description": "Job id returned by run_in_background." }
38 },
39 "anyOf": [
40 { "required": ["command"] },
41 { "required": ["background_action", "job_id"] }
42 ]
43 }),
44 )
45 }
46
47 fn handle(
48 &self,
49 args: &Map<String, Value>,
50 ctx: &ToolContext,
51 ) -> Result<ToolOutput, ErrorData> {
52 if let Some(action) = get_str(args, "background_action") {
53 let id = get_str(args, "job_id").ok_or_else(|| {
54 ErrorData::invalid_params("job_id is required with background_action", None)
55 })?;
56 let is_cancel = action == "cancel";
57 let state = match action.as_str() {
58 "status" => crate::server::background_shell::status(&id),
59 "cancel" => crate::server::background_shell::cancel(&id),
60 _ => {
61 return Err(ErrorData::invalid_params(
62 "background_action must be status or cancel",
63 None,
64 ));
65 }
66 };
67 let (text, exit_code) = format_background_state(&id, is_cancel, state);
68 return Ok(ToolOutput {
69 shell_outcome: Some(ShellOutcome::Exit(exit_code)),
70 content_blocks: None,
71 ..ToolOutput::simple(text)
72 });
73 }
74 let command = get_str(args, "command")
75 .ok_or_else(|| ErrorData::invalid_params("command is required", None))?;
76 let timeout_ms = get_int(args, "timeout_ms").and_then(|n| u64::try_from(n).ok());
77
78 let config = crate::core::config::Config::load();
83 let write_allow_paths = config.shell_write_allow_paths_effective();
84 let project_root = crate::core::config::Config::find_project_root();
85 if !config.shell_allow_writes_effective()
86 && let Some(rejection) =
87 crate::tools::ctx_shell::validate_command_with_write_allow_paths(
88 &command,
89 &write_allow_paths,
90 project_root.as_deref(),
91 )
92 {
93 return Ok(ToolOutput {
96 shell_outcome: Some(ShellOutcome::Blocked),
97 content_blocks: None,
98 ..ToolOutput::simple(rejection)
99 });
100 }
101
102 if let Err(msg) = crate::core::shell_allowlist::check_shell_allowlist(&command) {
103 return Ok(ToolOutput {
104 shell_outcome: Some(ShellOutcome::Blocked),
105 content_blocks: None,
106 ..ToolOutput::simple(msg.to_string())
107 });
108 }
109
110 warn_shell_secret_paths(&command);
111
112 if let Some(read_path) = detect_bare_cat_file(&command)
116 && let Some(cache_lock) = ctx.cache.as_ref()
117 && let Some(mut cache) = crate::server::bounded_lock::write(cache_lock, "cat_redirect")
118 {
119 let result = crate::tools::ctx_read::handle_with_task_resolved(
120 &mut cache,
121 &read_path,
122 "full",
123 crate::tools::CrpMode::Off,
124 None,
125 );
126 let note = format!(
127 "\n[ctx_shell: bare `cat` redirected to ctx_read for inline delivery. \
128 Use ctx_read(path=\"{read_path}\") directly next time.]"
129 );
130 let out = format!("{}{note}", result.content);
131 let sent = crate::core::tokens::count_tokens(&out);
132 return Ok(ToolOutput {
133 text: out,
134 original_tokens: sent,
135 saved_tokens: 0,
136 mode: Some("cat-redirect".to_string()),
137 path: Some(read_path),
138 changed: false,
139 shell_outcome: Some(ShellOutcome::Exit(0)),
140 content_blocks: None,
141 });
142 }
143
144 tokio::task::block_in_place(|| {
145 let session_lock = ctx
146 .session
147 .as_ref()
148 .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
149
150 let explicit_cwd = get_str(args, "cwd");
151 let had_explicit_cwd = explicit_cwd.is_some();
152 let (effective_cwd, cwd_jail_reason) = {
153 let guard = crate::server::bounded_lock::read(session_lock, "ctx_shell_cwd");
154 match guard {
155 Some(session) => session.effective_cwd_checked(explicit_cwd.as_deref()),
156 None => (explicit_cwd.unwrap_or_else(|| ".".to_string()), None),
157 }
158 };
159 let cwd_jail_reason_was_none = cwd_jail_reason.is_none();
164 let cwd_jail_hint = cwd_jail_reason.map_or_else(String::new, |reason| {
165 format!(
166 "\n[cwd: requested path rejected by project-root jail ({reason}) \u{2014} ran in {effective_cwd} instead]"
167 )
168 });
169
170 {
171 let Some(mut session) =
172 crate::server::bounded_lock::write(session_lock, "ctx_shell_write")
173 else {
174 tracing::debug!("[ctx_shell: session lock timeout, proceeding without update]");
175 let cmd_clone = command.clone();
176 let cwd_clone = effective_cwd.clone();
177 let extra_env: std::collections::HashMap<String, String> = args
178 .get("env")
179 .and_then(|v| v.as_object())
180 .map(|obj| {
181 obj.iter()
182 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
183 .filter(|(k, _)| !is_dangerous_env_key(k))
184 .collect()
185 })
186 .unwrap_or_default();
187 let (raw_output, exit_code) = crate::server::execute::execute_command_with_env(
188 &cmd_clone, &cwd_clone, &extra_env, timeout_ms,
189 );
190 let output = redact_shell_output_secrets(&raw_output);
191 let exit_suffix = match exit_code {
194 0 => String::new(),
195 124 => "\n[exit:124 — command timed out]".to_string(),
196 _ => format!("\n[exit:{exit_code}]"),
197 };
198 return Ok(ToolOutput {
199 shell_outcome: Some(ShellOutcome::Exit(exit_code)),
200 content_blocks: None,
201 ..ToolOutput::simple(format!("{output}{exit_suffix}"))
202 });
203 };
204 if had_explicit_cwd && cwd_jail_reason_was_none {
209 session.note_explicit_cwd(&effective_cwd);
210 }
211 session.update_shell_cwd(&command);
212 let root_missing = session
213 .project_root
214 .as_deref()
215 .is_none_or(|r| r.trim().is_empty());
216 if root_missing {
217 let home = dirs::home_dir().map(|h| h.to_string_lossy().to_string());
218 if let Some(root) = crate::core::protocol::detect_project_root(&effective_cwd)
219 && home.as_deref() != Some(root.as_str())
220 {
221 session.project_root = Some(root.clone());
222 crate::core::index_orchestrator::ensure_all_background(&root);
223 }
224 }
225 }
226
227 let arg_raw = get_bool(args, "raw").unwrap_or(false);
228 let arg_bypass = get_bool(args, "bypass").unwrap_or(false);
229 let env_disabled = std::env::var("LEAN_CTX_DISABLED").is_ok();
230 let env_raw = std::env::var("LEAN_CTX_RAW").is_ok();
231 let (raw, bypass) = resolve_shell_raw_flags(arg_raw, arg_bypass, env_disabled, env_raw);
232
233 let crp_mode = ctx.crp_mode;
234 let cmd_clone = command.clone();
235 let cwd_clone = effective_cwd;
236 let proactive_block = if raw {
237 None
238 } else {
239 crate::core::relevance_tracker::proactive_context(&format!(
240 "ctx_shell command={cmd_clone} cwd={cwd_clone}"
241 ))
242 };
243
244 let extra_env: std::collections::HashMap<String, String> = args
245 .get("env")
246 .and_then(|v| v.as_object())
247 .map(|obj| {
248 obj.iter()
249 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
250 .filter(|(k, _)| !is_dangerous_env_key(k))
251 .collect()
252 })
253 .unwrap_or_default();
254
255 let auto_background = should_auto_background(&cmd_clone, timeout_ms);
256 if get_bool(args, "run_in_background").unwrap_or(false) || auto_background {
257 let job_id = crate::server::background_shell::start(
258 cmd_clone, cwd_clone, extra_env, timeout_ms,
259 );
260 let mode = if auto_background {
261 "auto-background"
262 } else {
263 "background"
264 };
265 return Ok(ToolOutput {
266 shell_outcome: Some(ShellOutcome::Exit(0)),
267 content_blocks: None,
268 ..ToolOutput::simple(format!(
269 "[{mode}:{job_id} started — use ctx_shell(background_action=\"status\", job_id=\"{job_id}\") to poll or background_action=\"cancel\" to stop it]"
270 ))
271 });
272 }
273
274 let soft_cap = std::time::Duration::from_millis(foreground_soft_cap_ms());
285 let progress_sender = ctx.progress_sender.clone();
286 let progress_label: String = cmd_clone.chars().take(60).collect();
287 let cap_secs = soft_cap.as_secs_f64();
288 let on_tick = |elapsed: std::time::Duration| {
289 #[allow(clippy::unwrap_or_default)]
290 if let Some(ref ps) = progress_sender
291 && let Some(sender) = ps
292 .lock()
293 .unwrap_or_else(std::sync::PoisonError::into_inner)
294 .as_ref()
295 {
296 sender.send(
297 elapsed.as_secs_f64(),
298 Some(cap_secs),
299 Some(format!(
300 "ctx_shell: {}s elapsed — {progress_label}",
301 elapsed.as_secs()
302 )),
303 );
304 }
305 };
306 let (raw_output, exit_code) =
307 match crate::server::background_shell::run_foreground_or_detach(
308 cmd_clone.clone(),
309 cwd_clone.clone(),
310 extra_env.clone(),
311 timeout_ms,
312 soft_cap,
313 Some(&on_tick),
314 ) {
315 crate::server::background_shell::ForegroundResult::Finished {
316 output,
317 exit_code,
318 } => (output, exit_code),
319 crate::server::background_shell::ForegroundResult::Detached { job_id } => {
320 return Ok(ToolOutput {
321 shell_outcome: Some(ShellOutcome::Exit(0)),
322 content_blocks: None,
323 ..ToolOutput::simple(format!(
327 "[auto-background:{job_id} still running — passed the {}s foreground cap, not an error; output is kept and delivered by ctx_shell(background_action=\"status\", job_id=\"{job_id}\"), or background_action=\"cancel\" to stop it]",
328 soft_cap.as_secs()
329 ))
330 });
331 }
332 };
333
334 crate::core::diagnostics_store::record_from_shell(&cmd_clone, &raw_output, exit_code);
336
337 let output = redact_shell_output_secrets(&raw_output);
338
339 let inline = get_bool(args, "inline").unwrap_or(false);
340 let (result_out, original, saved, tee_hint) = if raw || inline {
341 let tokens = crate::core::tokens::count_tokens(&output);
342 (output, tokens, 0, String::new())
343 } else {
344 let _mode_guard = crate::core::savings_footer::ModeGuard::new("shell");
345 let result =
346 crate::tools::ctx_shell::handle(&cmd_clone, &output, exit_code, crp_mode);
347 let original = crate::core::tokens::count_tokens(&output);
348 let sent = crate::core::tokens::count_tokens(&result);
349 let saved = original.saturating_sub(sent);
350
351 let cfg = crate::core::config::Config::load();
352 let timeout_notice_only = is_timeout_notice_only(&output, exit_code);
355 let tee_hint = if crate::shell::tee_policy::should_tee(
356 &cfg.tee_mode,
357 exit_code,
358 output.trim().is_empty() || timeout_notice_only,
359 crate::shell::tee_policy::output_was_elided(&output, &result),
360 original,
361 sent,
362 ) {
363 crate::shell::save_tee(&cmd_clone, &output)
364 .map(|p| {
365 if matches!(cfg.tee_mode, crate::core::config::TeeMode::HighCompression)
366 {
367 let pct = crate::shell::tee_policy::savings_pct(original, sent);
368 format!(
371 "\n[compressed {pct:.0}%: full output at {p} — read it directly (no MCP), or ctx_expand(id=\"{p}\", search=\"…\"|head=N|json_path=\"…\") for a slice]"
372 )
373 } else {
374 format!("\n[full output: {p} — read it directly (no MCP), or ctx_expand(id=\"{p}\")]")
375 }
376 })
377 .unwrap_or_default()
378 } else {
379 String::new()
380 };
381
382 (result, original, saved, tee_hint)
383 };
384
385 let mode = if bypass {
386 Some("bypass".to_string())
387 } else if raw {
388 Some("raw".to_string())
389 } else {
390 None
391 };
392
393 let shell_mismatch = if cfg!(windows) && !raw {
394 shell_mismatch_hint(&command, &result_out)
395 } else {
396 String::new()
397 };
398
399 let result_out = crate::core::redaction::redact_text_if_enabled(&result_out);
400 let exit_suffix = match exit_code {
404 0 => String::new(),
405 124 => "\n[exit:124 — command timed out]".to_string(),
406 _ => format!("\n[exit:{exit_code}]"),
407 };
408 let nudge = if raw { "" } else { search_tool_nudge(&command) };
409 let final_out = format!(
410 "{result_out}{tee_hint}{shell_mismatch}{cwd_jail_hint}{nudge}{exit_suffix}"
411 );
412 let final_out = if let Some(block) = proactive_block {
413 format!("{final_out}{block}")
414 } else {
415 final_out
416 };
417
418 Ok(ToolOutput {
419 text: final_out,
420 original_tokens: original,
421 saved_tokens: saved,
422 mode,
423 path: None,
424 changed: false,
425 shell_outcome: Some(ShellOutcome::Exit(exit_code)),
426 content_blocks: None,
427 })
428 })
429 }
430}
431
432#[allow(clippy::fn_params_excessive_bools)]
433fn resolve_shell_raw_flags(
434 arg_raw: bool,
435 arg_bypass: bool,
436 env_disabled: bool,
437 env_raw: bool,
438) -> (bool, bool) {
439 let bypass = arg_bypass || env_raw;
440 let raw = arg_raw || bypass || env_disabled;
441 (raw, bypass)
442}
443
444fn is_timeout_notice_only(output: &str, exit_code: i32) -> bool {
451 exit_code == 124
452 && crate::server::execute::output_before_timeout_marker(output).is_some_and(str::is_empty)
453}
454
455fn search_tool_nudge(command: &str) -> &'static str {
456 let cmd = command.trim();
457 let first_word = cmd.split_whitespace().next().unwrap_or("");
458 if !cmd.contains('|') {
459 match first_word {
460 "grep" | "rg" | "egrep" | "fgrep" | "ag" => {
461 return "\n[hint: use ctx_search for structured, cached results with symbol/semantic modes]";
462 }
463 "find" => {
464 return "\n[hint: use ctx_glob or ctx_tree for structured file discovery]";
465 }
466 "ls" | "exa" | "eza" => {
467 return "\n[hint: use ctx_tree for structured directory listing]";
468 }
469 _ => {}
470 }
471 }
472 ""
473}
474
475fn shell_mismatch_hint(command: &str, output: &str) -> String {
476 let shell = crate::shell::shell_name();
477 let is_posix = matches!(shell.as_str(), "bash" | "sh" | "zsh" | "fish");
478 let has_error = output.contains("is not recognized")
479 || output.contains("not found")
480 || output.contains("command not found");
481
482 if !has_error {
483 return String::new();
484 }
485
486 let powershell_cmds = [
487 "Get-Content",
488 "Select-Object",
489 "Get-ChildItem",
490 "Set-Location",
491 "Where-Object",
492 "ForEach-Object",
493 "Select-String",
494 "Invoke-Expression",
495 "Write-Output",
496 ];
497 let uses_powershell = powershell_cmds
498 .iter()
499 .any(|c| command.contains(c) || command.contains(&c.to_lowercase()));
500
501 if is_posix && uses_powershell {
502 format!(
503 "\n[shell: {shell} — use POSIX commands (cat, head, grep, find, ls) not PowerShell cmdlets]"
504 )
505 } else {
506 String::new()
507 }
508}
509
510fn is_dangerous_env_key(key: &str) -> bool {
511 const BLOCKED: &[&str] = &[
512 "LD_PRELOAD",
514 "LD_LIBRARY_PATH",
515 "DYLD_INSERT_LIBRARIES",
516 "DYLD_LIBRARY_PATH",
517 "DYLD_FRAMEWORK_PATH",
518 "BASH_ENV",
520 "ENV",
521 "PROMPT_COMMAND",
522 "SHELL",
523 "IFS",
524 "CDPATH",
525 "PATH",
527 "GIT_EXEC_PATH",
528 "GIT_SSH",
529 "GIT_SSH_COMMAND",
530 "HOME",
532 "USER",
533 "LOGNAME",
534 "XDG_CONFIG_HOME",
535 "XDG_DATA_HOME",
536 "XDG_STATE_HOME",
537 "XDG_CACHE_HOME",
538 "PYTHONPATH",
540 "PYTHONSTARTUP",
541 "PYTHONHOME",
542 "NODE_PATH",
543 "NODE_OPTIONS",
544 "RUBYOPT",
545 "RUBYLIB",
546 "GEM_PATH",
547 "GEM_HOME",
548 "PERL5LIB",
549 "PERL5OPT",
550 "CLASSPATH",
551 "JAVA_HOME",
552 "CARGO_HOME",
553 "RUSTUP_HOME",
554 "GOPATH",
555 "GOROOT",
556 ];
557 let upper = key.to_uppercase();
558 if BLOCKED.contains(&upper.as_str()) {
559 return true;
560 }
561 if upper.starts_with("LD_") && upper.ends_with("_PATH") {
562 return true;
563 }
564 if upper.starts_with("LEAN_CTX_") || upper.starts_with("LCTX_") {
566 return true;
567 }
568 false
569}
570
571fn warn_shell_secret_paths(command: &str) {
574 const READ_CMDS: &[&str] = &["cat", "head", "tail", "less", "more", "bat"];
575 let segments = crate::core::shell_allowlist::extract_all_commands_pub(command);
576 for seg in &segments {
577 let trimmed = seg.trim();
578 let tokens = crate::core::shell_allowlist::shell_tokenize(trimmed);
579 if tokens.is_empty() {
580 continue;
581 }
582 let base = tokens[0]
583 .rsplit('/')
584 .next()
585 .unwrap_or(&tokens[0])
586 .to_string();
587 if !READ_CMDS.contains(&base.as_str()) {
588 continue;
589 }
590 for tok in &tokens[1..] {
591 if tok.starts_with('-') {
592 continue;
593 }
594 let path = std::path::Path::new(tok.as_str());
595 if crate::core::io_boundary::is_secret_like(path).is_some() {
596 tracing::warn!(
597 "[SECURITY] Shell reading secret-like path: {tok} (command: {base})"
598 );
599 }
600 }
601 }
602}
603
604fn format_background_state(
614 id: &str,
615 is_cancel: bool,
616 state: Option<crate::server::background_shell::JobState>,
617) -> (String, i32) {
618 use crate::server::background_shell::JobState;
619 let Some(state) = state else {
620 return if is_cancel {
621 (
622 format!("[background:{id} not found — already finished or cancelled]"),
623 0,
624 )
625 } else {
626 (format!("[background:{id} not found]"), 1)
627 };
628 };
629 match state {
630 JobState::Running { output } => {
631 let body = redact_shell_output_secrets(&output);
635 let head = if is_cancel {
636 format!(
637 "[background:{id} cancel requested — job is stopping; poll status for the final output]"
638 )
639 } else {
640 format!("[background:{id} running]")
641 };
642 if body.trim().is_empty() {
643 (head, 0)
644 } else {
645 (format!("{head}\n{body}"), 0)
646 }
647 }
648 JobState::Completed { output, exit_code } => (
649 format!(
650 "[background:{id} completed]\n{}{}",
651 redact_shell_output_secrets(&output),
652 if exit_code == 0 {
653 String::new()
654 } else {
655 format!("\n[exit:{exit_code}]")
656 }
657 ),
658 if is_cancel { 0 } else { exit_code },
659 ),
660 JobState::Cancelled { output } => (
661 format!(
662 "[background:{id} cancelled]\n{}\n[cancelled: {id}, exit 130]",
663 redact_shell_output_secrets(&output)
664 ),
665 0,
666 ),
667 }
668}
669
670fn redact_shell_output_secrets(output: &str) -> String {
671 let cfg = crate::core::config::Config::load();
672 if !cfg.secret_detection.enabled {
673 return output.to_string();
674 }
675 let (redacted, matches) =
676 crate::core::secret_detection::scan_and_redact(output, &cfg.secret_detection);
677 if !matches.is_empty() {
678 let names: Vec<&str> = matches.iter().map(|m| m.pattern_name).collect();
679 tracing::warn!(
680 "[SHELL SECRET REDACTION] {} secret(s) redacted from shell output: {}",
681 matches.len(),
682 names.join(", ")
683 );
684 }
685 redacted
686}
687
688fn should_auto_background(command: &str, timeout_ms: Option<u64>) -> bool {
693 timeout_ms.is_some_and(|timeout| timeout >= 300_000)
694 && command
695 .lines()
696 .any(|line| line.trim_start().starts_with("cargo test"))
697}
698
699fn foreground_soft_cap_ms() -> u64 {
704 std::env::var("LEAN_CTX_SHELL_FG_CAP_MS")
705 .ok()
706 .and_then(|v| v.parse().ok())
707 .filter(|&ms| ms > 0)
708 .unwrap_or(110_000)
709}
710
711fn detect_bare_cat_file(command: &str) -> Option<String> {
713 let trimmed = command.trim();
714 let rest = trimmed.strip_prefix("cat ")?;
715 let rest = rest.trim();
716 if rest.is_empty()
717 || rest.contains('|')
718 || rest.contains('>')
719 || rest.contains('<')
720 || rest.contains(';')
721 || rest.contains('&')
722 || rest.contains('$')
723 || rest.starts_with('-')
724 {
725 return None;
726 }
727 let parts: Vec<&str> = rest.split_whitespace().collect();
728 if parts.len() != 1 {
729 return None;
730 }
731 let file_path = parts[0].trim_matches(|c: char| c == '\'' || c == '"');
732 if file_path.is_empty() {
733 return None;
734 }
735 Some(file_path.to_string())
736}
737
738#[cfg(test)]
739mod tests {
740 use super::{format_background_state, is_timeout_notice_only, should_auto_background};
741 use crate::server::background_shell::JobState;
742
743 #[test]
746 fn cancel_is_acknowledged_and_never_reports_a_failure() {
747 let running = JobState::Running {
748 output: String::new(),
749 };
750 let (text, exit) = format_background_state("shell_x", true, Some(running.clone()));
751 assert_eq!(exit, 0);
752 assert!(text.contains("cancel requested"), "{text}");
753
754 let (text, exit) = format_background_state("shell_x", false, Some(running));
756 assert_eq!(exit, 0);
757 assert!(text.contains("[background:shell_x running]"), "{text}");
758
759 let (text, exit) = format_background_state(
761 "shell_x",
762 true,
763 Some(JobState::Cancelled {
764 output: "[cancelled: command stopped on request]".to_string(),
765 }),
766 );
767 assert_eq!(exit, 0);
768 assert!(text.contains("[cancelled: shell_x, exit 130]"), "{text}");
769
770 let finished = JobState::Completed {
772 output: "boom".to_string(),
773 exit_code: 1,
774 };
775 assert_eq!(
776 format_background_state("shell_x", true, Some(finished.clone())).1,
777 0
778 );
779 assert_eq!(
780 format_background_state("shell_x", false, Some(finished)).1,
781 1
782 );
783 assert_eq!(format_background_state("shell_x", true, None).1, 0);
784 assert_eq!(format_background_state("shell_x", false, None).1, 1);
785 }
786
787 #[test]
788 fn long_cargo_test_is_auto_backgrounded() {
789 assert!(should_auto_background(
790 "cargo test --lib a\ncargo test --lib b",
791 Some(3_600_000)
792 ));
793 assert!(should_auto_background("cargo test --lib a", Some(300_000)));
794 assert!(!should_auto_background("cargo test --lib a", Some(299_999)));
795 }
796
797 #[test]
798 fn timeout_notice_without_child_output_is_not_recoverable() {
799 assert!(is_timeout_notice_only(
800 "ERROR: command timed out after 200ms",
801 124
802 ));
803 assert!(is_timeout_notice_only(
804 " ERROR: command timed out after 200ms\n",
805 124
806 ));
807 assert!(!is_timeout_notice_only(
808 "useful output\nERROR: command timed out after 200ms",
809 124
810 ));
811 assert!(!is_timeout_notice_only(
812 "ERROR: command timed out after 200ms",
813 1
814 ));
815 assert!(is_timeout_notice_only(
819 "ERROR: command timed out after 200ms without new output\n\
820 [still running at timeout: sleep 300]",
821 124
822 ));
823 assert!(!is_timeout_notice_only(
824 "useful output\nERROR: command timed out after 200ms\n\
825 [still running at timeout: sleep 300]",
826 124
827 ));
828 assert!(!is_timeout_notice_only("some tool output", 124));
830 }
831}