1use std::{
2 ffi::OsString,
3 path::{Path, PathBuf},
4 process::ExitStatus,
5};
6
7use serde_json::{Map, Value};
8
9use crate::OpencodeError;
10
11#[derive(Debug, Clone, Eq, PartialEq)]
12pub struct OpencodeRunRequest {
13 prompt: String,
14 model: Option<String>,
15 session: Option<String>,
16 continue_session: bool,
17 fork: bool,
18 working_dir: Option<PathBuf>,
19}
20
21impl OpencodeRunRequest {
22 pub fn new(prompt: impl Into<String>) -> Self {
23 Self {
24 prompt: prompt.into(),
25 model: None,
26 session: None,
27 continue_session: false,
28 fork: false,
29 working_dir: None,
30 }
31 }
32
33 pub fn model(mut self, model: impl Into<String>) -> Self {
34 self.model = Some(model.into());
35 self
36 }
37
38 pub fn session(mut self, session_id: impl Into<String>) -> Self {
39 self.session = Some(session_id.into());
40 self
41 }
42
43 pub fn continue_session(mut self, value: bool) -> Self {
44 self.continue_session = value;
45 self
46 }
47
48 pub fn fork(mut self, value: bool) -> Self {
49 self.fork = value;
50 self
51 }
52
53 pub fn working_dir(mut self, path: impl Into<PathBuf>) -> Self {
54 self.working_dir = Some(path.into());
55 self
56 }
57
58 pub fn prompt(&self) -> &str {
59 &self.prompt
60 }
61
62 pub fn model_name(&self) -> Option<&str> {
63 self.model.as_deref()
64 }
65
66 pub fn session_id(&self) -> Option<&str> {
67 self.session.as_deref()
68 }
69
70 pub fn continue_requested(&self) -> bool {
71 self.continue_session
72 }
73
74 pub fn fork_requested(&self) -> bool {
75 self.fork
76 }
77
78 pub fn working_directory(&self) -> Option<&Path> {
79 self.working_dir.as_deref()
80 }
81
82 pub(crate) fn argv(&self) -> Result<Vec<OsString>, OpencodeError> {
83 if self.prompt.trim().is_empty() {
84 return Err(OpencodeError::InvalidRequest(
85 "prompt must not be empty".to_string(),
86 ));
87 }
88
89 let mut argv = vec![
90 OsString::from("run"),
91 OsString::from("--format"),
92 OsString::from("json"),
93 ];
94
95 if let Some(model) = normalize_non_empty(self.model.as_deref()) {
96 argv.push(OsString::from("--model"));
97 argv.push(OsString::from(model));
98 }
99
100 if let Some(session) = normalize_non_empty(self.session.as_deref()) {
101 argv.push(OsString::from("--session"));
102 argv.push(OsString::from(session));
103 }
104
105 if self.continue_session {
106 argv.push(OsString::from("--continue"));
107 }
108
109 if self.fork {
110 argv.push(OsString::from("--fork"));
111 }
112
113 if let Some(path) = &self.working_dir {
114 argv.push(OsString::from("--dir"));
115 argv.push(path.as_os_str().to_os_string());
116 }
117
118 argv.push(OsString::from(self.prompt.as_str()));
119 Ok(argv)
120 }
121}
122
123fn normalize_non_empty(value: Option<&str>) -> Option<String> {
124 value
125 .map(str::trim)
126 .filter(|value| !value.is_empty())
127 .map(ToOwned::to_owned)
128}
129
130#[derive(Debug, Clone, Eq, PartialEq)]
131pub struct OpencodeRunCompletion {
132 pub status: ExitStatus,
133 pub final_text: Option<String>,
134}
135
136#[derive(Debug, Clone, Copy, Eq, PartialEq)]
137pub enum OpencodeRunJsonErrorCode {
138 JsonParse,
139 TypedParse,
140}
141
142#[derive(Debug, Clone, thiserror::Error)]
143#[error("{message}")]
144pub struct OpencodeRunJsonParseError {
145 pub code: OpencodeRunJsonErrorCode,
146 pub message: String,
147 pub details: String,
148}
149
150impl OpencodeRunJsonParseError {
151 fn new(code: OpencodeRunJsonErrorCode, message: String) -> Self {
152 Self {
153 code,
154 details: message.clone(),
155 message,
156 }
157 }
158}
159
160#[derive(Debug, Clone)]
161pub enum OpencodeRunJsonEvent {
162 StepStart {
163 session_id: Option<String>,
164 raw: Value,
165 },
166 Text {
167 session_id: Option<String>,
168 text: String,
169 raw: Value,
170 },
171 StepFinish {
172 session_id: Option<String>,
173 raw: Value,
174 },
175 Unknown {
176 event_type: String,
177 session_id: Option<String>,
178 raw: Value,
179 },
180 TerminalError {
181 message: String,
182 raw: Value,
183 },
184}
185
186impl OpencodeRunJsonEvent {
187 pub fn raw(&self) -> &Value {
188 match self {
189 OpencodeRunJsonEvent::StepStart { raw, .. } => raw,
190 OpencodeRunJsonEvent::Text { raw, .. } => raw,
191 OpencodeRunJsonEvent::StepFinish { raw, .. } => raw,
192 OpencodeRunJsonEvent::Unknown { raw, .. } => raw,
193 OpencodeRunJsonEvent::TerminalError { raw, .. } => raw,
194 }
195 }
196
197 pub fn event_type(&self) -> &str {
198 match self {
199 OpencodeRunJsonEvent::StepStart { .. } => "step_start",
200 OpencodeRunJsonEvent::Text { .. } => "text",
201 OpencodeRunJsonEvent::StepFinish { .. } => "step_finish",
202 OpencodeRunJsonEvent::Unknown { event_type, .. } => event_type.as_str(),
203 OpencodeRunJsonEvent::TerminalError { .. } => "terminal_error",
204 }
205 }
206
207 pub fn session_id(&self) -> Option<&str> {
208 match self {
209 OpencodeRunJsonEvent::StepStart { session_id, .. } => session_id.as_deref(),
210 OpencodeRunJsonEvent::Text { session_id, .. } => session_id.as_deref(),
211 OpencodeRunJsonEvent::StepFinish { session_id, .. } => session_id.as_deref(),
212 OpencodeRunJsonEvent::Unknown { session_id, .. } => session_id.as_deref(),
213 OpencodeRunJsonEvent::TerminalError { .. } => None,
214 }
215 }
216}
217
218#[derive(Debug, Clone, Default)]
219pub struct OpencodeRunJsonParser {
220 last_session_id: Option<String>,
221}
222
223impl OpencodeRunJsonParser {
224 pub fn new() -> Self {
225 Self::default()
226 }
227
228 pub fn reset(&mut self) {
229 self.last_session_id = None;
230 }
231
232 pub fn parse_line(
233 &mut self,
234 line: &str,
235 ) -> Result<Option<OpencodeRunJsonEvent>, OpencodeRunJsonParseError> {
236 let line = line.strip_suffix('\r').unwrap_or(line);
237 if line.chars().all(|ch| ch.is_whitespace()) {
238 return Ok(None);
239 }
240
241 let value: Value = serde_json::from_str(line).map_err(|err| {
242 OpencodeRunJsonParseError::new(
243 OpencodeRunJsonErrorCode::JsonParse,
244 format!("invalid JSON: {err}"),
245 )
246 })?;
247
248 self.parse_json(&value)
249 }
250
251 pub fn parse_json(
252 &mut self,
253 value: &Value,
254 ) -> Result<Option<OpencodeRunJsonEvent>, OpencodeRunJsonParseError> {
255 let obj = value.as_object().ok_or_else(|| {
256 OpencodeRunJsonParseError::new(
257 OpencodeRunJsonErrorCode::TypedParse,
258 "expected JSON object".to_string(),
259 )
260 })?;
261
262 let event_type = get_required_str(obj, "type").map_err(|message| {
263 OpencodeRunJsonParseError::new(OpencodeRunJsonErrorCode::TypedParse, message)
264 })?;
265
266 let session_id = get_optional_session_id(obj).or_else(|| self.last_session_id.clone());
267
268 let event = match event_type.as_str() {
269 "step_start" => OpencodeRunJsonEvent::StepStart {
270 session_id,
271 raw: value.clone(),
272 },
273 "text" => {
274 let text = get_required_str(obj, "text").map_err(|message| {
275 OpencodeRunJsonParseError::new(OpencodeRunJsonErrorCode::TypedParse, message)
276 })?;
277 OpencodeRunJsonEvent::Text {
278 session_id,
279 text,
280 raw: value.clone(),
281 }
282 }
283 "step_finish" => OpencodeRunJsonEvent::StepFinish {
284 session_id,
285 raw: value.clone(),
286 },
287 _ => OpencodeRunJsonEvent::Unknown {
288 event_type,
289 session_id,
290 raw: value.clone(),
291 },
292 };
293
294 if let Some(session_id) = event.session_id() {
295 self.last_session_id = Some(session_id.to_string());
296 }
297
298 Ok(Some(event))
299 }
300}
301
302#[derive(Debug, Clone, Eq, PartialEq)]
303pub struct OpencodeRunJsonLine {
304 pub line_number: usize,
305 pub raw: String,
306}
307
308#[derive(Debug, Clone)]
309pub enum OpencodeRunJsonLineOutcome {
310 Ok {
311 line: OpencodeRunJsonLine,
312 event: OpencodeRunJsonEvent,
313 },
314 Err {
315 line: OpencodeRunJsonLine,
316 error: OpencodeRunJsonParseError,
317 },
318}
319
320pub fn parse_run_json_lines(input: &str) -> Vec<OpencodeRunJsonLineOutcome> {
321 let mut parser = OpencodeRunJsonParser::new();
322 let mut outcomes = Vec::new();
323
324 for (index, raw) in input.lines().enumerate() {
325 let line = OpencodeRunJsonLine {
326 line_number: index + 1,
327 raw: raw.to_string(),
328 };
329
330 match parser.parse_line(raw) {
331 Ok(Some(event)) => outcomes.push(OpencodeRunJsonLineOutcome::Ok { line, event }),
332 Ok(None) => {}
333 Err(error) => outcomes.push(OpencodeRunJsonLineOutcome::Err { line, error }),
334 }
335 }
336
337 outcomes
338}
339
340fn get_required_str(obj: &Map<String, Value>, key: &str) -> Result<String, String> {
341 obj.get(key)
342 .and_then(Value::as_str)
343 .map(ToOwned::to_owned)
344 .ok_or_else(|| format!("expected string field `{key}`"))
345}
346
347fn get_optional_session_id(obj: &Map<String, Value>) -> Option<String> {
348 ["session_id", "sessionId"]
349 .into_iter()
350 .find_map(|key| obj.get(key).and_then(Value::as_str))
351 .map(ToOwned::to_owned)
352}