1use std::collections::BTreeMap;
2use std::io::{self, Write};
3use std::path::PathBuf;
4use std::process::{Command, Stdio};
5use std::sync::{Arc, Mutex};
6use std::thread;
7
8#[derive(Clone, Debug, PartialEq, Eq)]
9pub struct CommandSpec {
10 pub argv: Vec<String>,
11 pub stdin_text: Option<String>,
12 pub cwd: Option<PathBuf>,
13 pub env: BTreeMap<String, String>,
14}
15
16impl CommandSpec {
17 pub fn new<I, S>(argv: I) -> Self
18 where
19 I: IntoIterator<Item = S>,
20 S: Into<String>,
21 {
22 Self {
23 argv: argv.into_iter().map(Into::into).collect(),
24 stdin_text: None,
25 cwd: None,
26 env: BTreeMap::new(),
27 }
28 }
29
30 pub fn with_stdin(mut self, stdin_text: impl Into<String>) -> Self {
31 self.stdin_text = Some(stdin_text.into());
32 self
33 }
34
35 pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
36 self.cwd = Some(cwd.into());
37 self
38 }
39
40 pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
41 self.env.insert(key.into(), value.into());
42 self
43 }
44}
45
46#[derive(Clone, Debug, PartialEq, Eq)]
47pub struct CompletedRun {
48 pub argv: Vec<String>,
49 pub exit_code: i32,
50 pub stdout: String,
51 pub stderr: String,
52}
53
54type StreamCallback = Arc<Mutex<dyn FnMut(&str, &str) + Send>>;
55type RunExecutor = dyn Fn(CommandSpec) -> CompletedRun + Send + Sync;
56type StreamExecutor = dyn Fn(CommandSpec, StreamCallback) -> CompletedRun + Send + Sync;
57
58pub struct Runner {
59 executor: Box<RunExecutor>,
60 stream_executor: Box<StreamExecutor>,
61}
62
63impl Runner {
64 pub fn new() -> Self {
65 Self {
66 executor: Box::new(default_run_executor),
67 stream_executor: Box::new(default_stream_executor),
68 }
69 }
70
71 pub fn with_executor(executor: Box<RunExecutor>) -> Self {
72 Self {
73 executor,
74 stream_executor: Box::new(default_stream_executor),
75 }
76 }
77
78 pub fn with_stream_executor(stream_executor: Box<StreamExecutor>) -> Self {
79 Self {
80 executor: Box::new(default_run_executor),
81 stream_executor,
82 }
83 }
84
85 pub fn run(&self, spec: CommandSpec) -> CompletedRun {
86 (self.executor)(spec)
87 }
88
89 pub fn stream<F>(&self, spec: CommandSpec, on_event: F) -> CompletedRun
90 where
91 F: FnMut(&str, &str) + Send + 'static,
92 {
93 (self.stream_executor)(spec, Arc::new(Mutex::new(on_event)))
94 }
95}
96
97impl Default for Runner {
98 fn default() -> Self {
99 Self::new()
100 }
101}
102
103pub fn build_prompt_spec(prompt: &str) -> Result<CommandSpec, &'static str> {
104 let normalized_prompt = prompt.trim();
105 if normalized_prompt.is_empty() {
106 return Err("prompt must not be empty");
107 }
108 Ok(CommandSpec::new(["opencode", "run", normalized_prompt]))
109}
110
111fn default_run_executor(spec: CommandSpec) -> CompletedRun {
112 let mut command = build_command(&spec);
113 let output = command
114 .output()
115 .unwrap_or_else(|error| failed_output(&spec, error));
116 CompletedRun {
117 argv: spec.argv,
118 exit_code: output.status.code().unwrap_or(1),
119 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
120 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
121 }
122}
123
124fn default_stream_executor(spec: CommandSpec, callback: StreamCallback) -> CompletedRun {
125 let argv = spec.argv.clone();
126 let mut command = build_command(&spec);
127 command.stdout(Stdio::piped());
128 command.stderr(Stdio::piped());
129
130 let mut child = match command.spawn() {
131 Ok(child) => child,
132 Err(error) => {
133 let error_msg = format!(
134 "failed to start {}: {}",
135 spec.argv.first().map(|s| s.as_str()).unwrap_or("(unknown)"),
136 error
137 );
138 if let Ok(mut cb) = callback.lock() {
139 cb("stderr", &error_msg);
140 }
141 return CompletedRun {
142 argv,
143 exit_code: 1,
144 stdout: String::new(),
145 stderr: error_msg,
146 };
147 }
148 };
149
150 if let Some(stdin_text) = &spec.stdin_text {
151 if let Some(mut stdin) = child.stdin.take() {
152 let _ = stdin.write_all(stdin_text.as_bytes());
153 }
154 }
155
156 let stdout_pipe = child.stdout.take();
157 let stderr_pipe = child.stderr.take();
158
159 let cb_out = Arc::clone(&callback);
160 let stdout_thread = thread::spawn(move || {
161 let mut buf = String::new();
162 if let Some(pipe) = stdout_pipe {
163 use std::io::BufRead;
164 let reader = std::io::BufReader::new(pipe);
165 for line in reader.lines() {
166 match line {
167 Ok(text) => {
168 buf.push_str(&text);
169 buf.push('\n');
170 let chunk = format!("{text}\n");
171 if let Ok(mut cb) = cb_out.lock() {
172 cb("stdout", &chunk);
173 }
174 }
175 Err(_) => break,
176 }
177 }
178 }
179 buf
180 });
181
182 let cb_err = Arc::clone(&callback);
183 let stderr_thread = thread::spawn(move || {
184 let mut buf = String::new();
185 if let Some(pipe) = stderr_pipe {
186 use std::io::BufRead;
187 let reader = std::io::BufReader::new(pipe);
188 for line in reader.lines() {
189 match line {
190 Ok(text) => {
191 buf.push_str(&text);
192 buf.push('\n');
193 let chunk = format!("{text}\n");
194 if let Ok(mut cb) = cb_err.lock() {
195 cb("stderr", &chunk);
196 }
197 }
198 Err(_) => break,
199 }
200 }
201 }
202 buf
203 });
204
205 let stdout_buf = stdout_thread.join().unwrap_or_default();
206 let stderr_buf = stderr_thread.join().unwrap_or_default();
207
208 let status = child.wait().unwrap_or_else(|error| {
209 exit_status_from_code(failed_output(&spec, error).status.code().unwrap_or(1))
210 });
211
212 CompletedRun {
213 argv,
214 exit_code: status.code().unwrap_or(1),
215 stdout: stdout_buf,
216 stderr: stderr_buf,
217 }
218}
219
220fn build_command(spec: &CommandSpec) -> Command {
221 let mut argv = spec.argv.iter();
222 let program = argv.next().cloned().unwrap_or_default();
223 let mut command = Command::new(program);
224 command.args(argv);
225 if let Some(cwd) = &spec.cwd {
226 command.current_dir(cwd);
227 }
228 command.envs(&spec.env);
229 command.stdin(if spec.stdin_text.is_some() {
230 Stdio::piped()
231 } else {
232 Stdio::null()
233 });
234 command
235}
236
237fn failed_output(spec: &CommandSpec, error: io::Error) -> std::process::Output {
238 let stderr = format!(
239 "failed to start {}: {}",
240 spec.argv.first().map(|s| s.as_str()).unwrap_or("(unknown)"),
241 error
242 )
243 .into_bytes();
244 std::process::Output {
245 status: exit_status_from_code(1),
246 stdout: Vec::new(),
247 stderr,
248 }
249}
250
251#[cfg(unix)]
252fn exit_status_from_code(code: i32) -> std::process::ExitStatus {
253 std::process::ExitStatus::from_raw(code << 8)
254}
255
256#[cfg(windows)]
257fn exit_status_from_code(code: i32) -> std::process::ExitStatus {
258 std::process::ExitStatus::from_raw(code as u32)
259}
260
261#[cfg(unix)]
262use std::os::unix::process::ExitStatusExt;
263
264#[cfg(windows)]
265use std::os::windows::process::ExitStatusExt;