Skip to main content

opencode/
run_json.rs

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}