1use std::collections::VecDeque;
5use std::fs::File;
6use std::io::Write;
7
8use tokio::time::Instant;
9use tokio_util::sync::CancellationToken;
10
11use super::accumulator::{Utf8Decoder, create_output_file};
12use super::exec::{ExecEnd, ExecError, ExecRequest, OutputSink, ShellSpec, exec};
13use super::shell::{sanitize_binary_output, shell_env, split_incomplete_ansi_suffix, strip_ansi};
14use super::temp_dir;
15use crate::env::ExecutionEnv;
16use crate::js;
17use crate::truncate::{DEFAULT_MAX_BYTES, TruncationOptions, truncate_tail};
18
19#[derive(Clone, Debug, Default, PartialEq, Eq)]
21pub struct ShellRunOptions {
22 pub shell_path: Option<String>,
24 pub agent_dir: String,
26 pub process_env: Option<Vec<(String, String)>>,
28}
29
30#[derive(Clone, Debug, PartialEq, Eq)]
32pub struct BashOutcome {
33 pub output: String,
36 pub exit_code: Option<i32>,
38 pub cancelled: bool,
40 pub truncated: bool,
42 pub full_output_path: Option<String>,
44}
45
46#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
48#[error("{0}")]
49pub struct ShellRunError(pub String);
50
51struct ExecutorSink<'a> {
52 on_chunk: &'a mut (dyn FnMut(&str) + Send),
53 decoder: Utf8Decoder,
54 pending_ansi: String,
55 chunks: VecDeque<String>,
56 chunk_units: usize,
57 total_bytes: u64,
58 temp_dir: String,
59 temp: Option<(String, File)>,
60}
61
62impl ExecutorSink<'_> {
63 fn ensure_temp_file(&mut self) {
64 if self.temp.is_some() {
65 return;
66 }
67 if let Ok((path, mut file)) = create_output_file(&self.temp_dir, "oino-bash") {
68 for chunk in &self.chunks {
69 let _ = file.write_all(chunk.as_bytes());
71 }
72 self.temp = Some((path, file));
73 }
74 }
75
76 fn append_text(&mut self, raw: &str) {
77 let text = sanitize_binary_output(&strip_ansi(raw)).replace('\r', "");
78 if text.is_empty() {
79 return;
80 }
81 if self.total_bytes > DEFAULT_MAX_BYTES {
82 self.ensure_temp_file();
83 }
84 if let Some((_, file)) = &mut self.temp {
85 let _ = file.write_all(text.as_bytes());
86 }
87 let max_units = usize::try_from(DEFAULT_MAX_BYTES * 2).unwrap_or(usize::MAX);
88 self.chunk_units += js::utf16_len(&text);
89 self.chunks.push_back(text.clone());
90 while self.chunk_units > max_units && self.chunks.len() > 1 {
91 if let Some(removed) = self.chunks.pop_front() {
92 self.chunk_units -= js::utf16_len(&removed);
93 }
94 }
95 (self.on_chunk)(&text);
96 }
97
98 fn flush_output(&mut self) {
99 let rest = format!(
100 "{}{}",
101 std::mem::take(&mut self.pending_ansi),
102 self.decoder.finish()
103 );
104 self.append_text(&rest);
105 }
106}
107
108impl OutputSink for ExecutorSink<'_> {
109 fn data(&mut self, chunk: &[u8]) {
110 self.total_bytes += chunk.len() as u64;
111 let decoded = format!("{}{}", self.pending_ansi, self.decoder.decode(chunk));
112 let (complete, pending) = split_incomplete_ansi_suffix(&decoded);
113 self.pending_ansi = pending;
114 self.append_text(&complete);
115 }
116
117 fn next_flush(&self) -> Option<Instant> {
118 None
119 }
120
121 fn flush(&mut self) {}
122}
123
124pub async fn run_shell_command(
132 env: &dyn ExecutionEnv,
133 command: &str,
134 cwd: &str,
135 options: &ShellRunOptions,
136 on_chunk: &mut (dyn FnMut(&str) + Send),
137 cancel: &CancellationToken,
138) -> Result<BashOutcome, ShellRunError> {
139 let mut sink = ExecutorSink {
140 on_chunk,
141 decoder: Utf8Decoder::default(),
142 pending_ansi: String::new(),
143 chunks: VecDeque::new(),
144 chunk_units: 0,
145 total_bytes: 0,
146 temp_dir: temp_dir(&options.agent_dir),
147 temp: None,
148 };
149 let request = ExecRequest {
150 env,
151 shell: ShellSpec::Bash(options.shell_path.as_deref()),
152 command: command.to_owned(),
153 cwd: cwd.to_owned(),
154 vars: shell_env(options.process_env.as_deref(), &options.agent_dir),
155 timeout: None,
156 };
157 let exit_code = match exec(request, &mut sink, cancel, None).await {
158 Ok(ExecEnd::Exited(code)) => Some(code),
159 Ok(ExecEnd::Detached(..)) | Err(ExecError::Aborted | ExecError::Timeout(_)) => None,
160 Err(ExecError::Failed(message)) => {
161 if !cancel.is_cancelled() {
162 return Err(ShellRunError(message));
163 }
164 None
165 }
166 };
167 sink.flush_output();
168 let full_output: String = sink.chunks.iter().map(String::as_str).collect();
169 let truncation = truncate_tail(&full_output, TruncationOptions::default());
170 if truncation.truncated {
171 sink.ensure_temp_file();
172 }
173 let cancelled = cancel.is_cancelled();
174 let full_output_path = sink.temp.take().map(|(path, mut file)| {
175 let _ = file.flush();
176 path
177 });
178 Ok(BashOutcome {
179 output: if truncation.truncated {
180 truncation.content
181 } else {
182 full_output
183 },
184 exit_code: if cancelled { None } else { exit_code },
185 cancelled,
186 truncated: truncation.truncated,
187 full_output_path,
188 })
189}