use crate::domain::report::OutputFormat;
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Extracted {
pub text: String,
pub source: String,
}
const TEXT_KEYS: &[&str] = &["result", "text", "message", "output", "content", "response"];
pub fn extract(stdout: &str, fmt: OutputFormat) -> Option<Extracted> {
match fmt {
OutputFormat::Text => extract_text(stdout),
OutputFormat::Json => extract_json(stdout),
OutputFormat::StreamJson => extract_stream_json(stdout),
}
}
fn extract_text(stdout: &str) -> Option<Extracted> {
let t = stdout.trim();
(!t.is_empty()).then(|| Extracted {
text: t.to_string(),
source: "raw".to_string(),
})
}
fn extract_json(stdout: &str) -> Option<Extracted> {
if let Ok(value) = serde_json::from_str::<Value>(stdout.trim()) {
if let Some((text, key)) = json_text(&value) {
return Some(Extracted {
text,
source: format!("json:{key}"),
});
}
}
extract_opencode_parts(stdout)
}
fn extract_opencode_parts(stdout: &str) -> Option<Extracted> {
let mut texts: Vec<String> = Vec::new();
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(value) = serde_json::from_str::<Value>(line) else {
continue;
};
let Some(part) = value.get("part").and_then(Value::as_object) else {
continue;
};
if part.get("type").and_then(Value::as_str) != Some("text") {
continue;
}
if let Some(text) = part.get("text").and_then(Value::as_str) {
if !text.trim().is_empty() {
texts.push(text.to_string());
}
}
}
(!texts.is_empty()).then(|| Extracted {
text: texts.join("\n"),
source: "json:opencode-parts".to_string(),
})
}
fn extract_stream_json(stdout: &str) -> Option<Extracted> {
let mut last: Option<(String, &'static str)> = None;
let mut last_result: Option<String> = None;
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(value) = serde_json::from_str::<Value>(line) else {
continue;
};
if let Some((text, key)) = json_text(&value) {
if key == "result" {
last_result = Some(text.clone());
}
last = Some((text, key));
}
}
if let Some(text) = last_result {
return Some(Extracted {
text,
source: "stream-json:result".to_string(),
});
}
last.map(|(text, key)| Extracted {
text,
source: format!("stream-json:{key}"),
})
}
fn json_text(value: &Value) -> Option<(String, &'static str)> {
match value {
Value::String(s) if !s.trim().is_empty() => Some((s.clone(), "string")),
Value::Object(map) => {
for key in TEXT_KEYS {
if let Some(Value::String(s)) = map.get(*key) {
if !s.trim().is_empty() {
return Some((s.clone(), key));
}
}
}
None
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_format_trims_raw_stdout() {
let got = extract(" hi there\n\n", OutputFormat::Text).unwrap();
assert_eq!(got.text, "hi there");
assert_eq!(got.source, "raw");
}
#[test]
fn empty_text_yields_none() {
assert!(extract(" \n", OutputFormat::Text).is_none());
}
#[test]
fn json_prefers_result_field() {
let raw = r#"{"type":"result","result":"the answer","is_error":false}"#;
let got = extract(raw, OutputFormat::Json).unwrap();
assert_eq!(got.text, "the answer");
assert_eq!(got.source, "json:result");
}
#[test]
fn json_falls_back_to_other_known_keys() {
let got = extract(r#"{"message":"hello"}"#, OutputFormat::Json).unwrap();
assert_eq!(got.text, "hello");
assert_eq!(got.source, "json:message");
}
#[test]
fn bare_json_string_is_extracted() {
let got = extract(r#""just a string""#, OutputFormat::Json).unwrap();
assert_eq!(got.text, "just a string");
assert_eq!(got.source, "json:string");
}
#[test]
fn unparseable_json_yields_none() {
assert!(extract("not json at all", OutputFormat::Json).is_none());
}
#[test]
fn stream_json_takes_terminal_result_event() {
let raw = concat!(
"{\"type\":\"system\"}\n",
"{\"type\":\"assistant\",\"text\":\"thinking\"}\n",
"{\"type\":\"result\",\"result\":\"final\"}\n",
);
let got = extract(raw, OutputFormat::StreamJson).unwrap();
assert_eq!(got.text, "final");
assert_eq!(got.source, "stream-json:result");
}
#[test]
fn stream_json_falls_back_to_last_text_without_result() {
let raw = "{\"type\":\"assistant\",\"text\":\"first\"}\n{\"type\":\"assistant\",\"text\":\"second\"}\n";
let got = extract(raw, OutputFormat::StreamJson).unwrap();
assert_eq!(got.text, "second");
assert_eq!(got.source, "stream-json:text");
}
const OPENCODE_RUN_JSONL: &str = concat!(
r#"{"type":"step_start","timestamp":1781179518088,"sessionID":"ses_1496d7d5effenFlBINeoCqBVk8","part":{"id":"prt_eb6928c85001I7CgUE9rTv6VJ0","messageID":"msg_eb69284ee001f9gKyD7PZudPCV","sessionID":"ses_1496d7d5effenFlBINeoCqBVk8","type":"step-start"}}"#,
"\n",
r#"{"type":"text","timestamp":1781179518140,"sessionID":"ses_1496d7d5effenFlBINeoCqBVk8","part":{"id":"prt_eb6928c87001tfso5uqcn63dxP","messageID":"msg_eb69284ee001f9gKyD7PZudPCV","sessionID":"ses_1496d7d5effenFlBINeoCqBVk8","type":"text","text":"PING-123","time":{"start":1781179518087,"end":1781179518139}}}"#,
"\n",
r#"{"type":"step_finish","timestamp":1781179518188,"sessionID":"ses_1496d7d5effenFlBINeoCqBVk8","part":{"id":"prt_eb6928ce6001n1g7PPB9g6pC3D","reason":"stop","messageID":"msg_eb69284ee001f9gKyD7PZudPCV","sessionID":"ses_1496d7d5effenFlBINeoCqBVk8","type":"step-finish","tokens":{"total":8186,"input":3,"output":7,"reasoning":0,"cache":{"write":8176,"read":0}},"cost":0.010258}}"#,
"\n",
);
#[test]
fn opencode_jsonl_extracts_text_part_under_json_format() {
let got = extract(OPENCODE_RUN_JSONL, OutputFormat::Json).unwrap();
assert_eq!(got.text, "PING-123");
assert_eq!(got.source, "json:opencode-parts");
}
#[test]
fn opencode_jsonl_joins_text_parts_and_skips_tool_and_step_parts() {
let raw = concat!(
r#"{"type":"step_start","sessionID":"ses_x","part":{"id":"p0","type":"step-start"}}"#,
"\n",
r#"{"type":"text","sessionID":"ses_x","part":{"id":"p1","type":"text","text":"I'll run that shell command for you."}}"#,
"\n",
r#"{"type":"tool_use","sessionID":"ses_x","part":{"id":"p2","type":"tool","tool":"bash","state":{"status":"completed","output":"HELLO-FROM-TOOL"}}}"#,
"\n",
r#"{"type":"text","sessionID":"ses_x","part":{"id":"p3","type":"text","text":"The command printed: `HELLO-FROM-TOOL`\n\nFINI-42"}}"#,
"\n",
r#"{"type":"step_finish","sessionID":"ses_x","part":{"id":"p4","type":"step-finish","cost":0.01,"tokens":{"input":3,"output":7}}}"#,
"\n",
);
let got = extract(raw, OutputFormat::Json).unwrap();
assert_eq!(
got.text,
"I'll run that shell command for you.\nThe command printed: `HELLO-FROM-TOOL`\n\nFINI-42"
);
assert_eq!(got.source, "json:opencode-parts");
assert!(!got.text.contains("step-finish"));
}
#[test]
fn opencode_jsonl_with_no_text_parts_yields_none() {
let raw = concat!(
r#"{"type":"step_start","sessionID":"ses_x","part":{"id":"p0","type":"step-start"}}"#,
"\n",
r#"{"type":"step_finish","sessionID":"ses_x","part":{"id":"p1","type":"step-finish","cost":0.01}}"#,
"\n",
);
assert!(extract(raw, OutputFormat::Json).is_none());
}
#[test]
fn claude_single_document_still_wins_over_jsonl_fallback() {
let raw = r#"{"type":"result","result":"the answer","is_error":false}"#;
let got = extract(raw, OutputFormat::Json).unwrap();
assert_eq!(got.text, "the answer");
assert_eq!(got.source, "json:result");
}
#[test]
fn opencode_parts_skip_blank_unparseable_and_partless_lines() {
let raw = concat!(
"\n",
" \n",
"not json at all\n",
r#"{"type":"noise"}"#,
"\n",
r#"{"type":"reasoning","part":{"type":"reasoning","text":"thinking out loud"}}"#,
"\n",
r#"{"type":"text","part":{"type":"text","text":" "}}"#,
"\n",
r#"{"type":"text","part":{"type":"text","text":"REAL-ANSWER"}}"#,
"\n",
);
let got = extract(raw, OutputFormat::Json).unwrap();
assert_eq!(got.text, "REAL-ANSWER");
assert_eq!(got.source, "json:opencode-parts");
}
#[test]
fn stream_json_skips_blank_and_unparseable_lines() {
let raw = concat!(
"\n",
"<<< not json >>>\n",
"{\"type\":\"assistant\",\"text\":\"only-line\"}\n",
);
let got = extract(raw, OutputFormat::StreamJson).unwrap();
assert_eq!(got.text, "only-line");
assert_eq!(got.source, "stream-json:text");
}
#[test]
fn stream_json_with_no_text_yields_none() {
let raw = "{\"type\":\"system\"}\n{\"type\":\"ping\"}\n";
assert!(extract(raw, OutputFormat::StreamJson).is_none());
}
#[test]
fn json_text_ignores_non_string_and_empty_key_values() {
assert!(extract(r#"{"result":42}"#, OutputFormat::Json).is_none());
assert!(extract(r#"{"result":" "}"#, OutputFormat::Json).is_none());
assert!(extract("12345", OutputFormat::Json).is_none());
}
}