// SPDX-License-Identifier: LGPL-2.1-or-later
// Copyright (c) 2026 Jarkko Sakkinen
//! Data-driven integration tests. A single fixture tree stages one store per
//! provider (see [`Fixture`]); each line of `tests/data.txt` then drives one
//! `goosedump` invocation and matches the exit status and captured stdout
//! against the expectations. See `tests/data.txt` for the field syntax.
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use rusqlite::{Connection, params};
const DATA: &str = include_str!("data.txt");
const CODEX_ID: &str = "019eb466-8e72-7dd0-8a10-9c8963f7d86c";
fn main() {
let fixture = Fixture::build();
let mut failed = 0u32;
let mut ran = 0u32;
for raw in DATA.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
ran += 1;
let case = Case::parse(line);
print!("Test {} ... ", case.name);
std::io::stdout().flush().expect("flush stdout");
match case.run(&fixture) {
Ok(()) => println!("ok"),
Err(reason) => {
println!("FAILED");
eprintln!(" {}: {reason}", case.name);
failed += 1;
}
}
}
for (name, result) in roundtrip_cases(&fixture) {
ran += 1;
print!("Test {name} ... ");
std::io::stdout().flush().expect("flush stdout");
match result {
Ok(()) => println!("ok"),
Err(reason) => {
println!("FAILED");
eprintln!(" {name}: {reason}");
failed += 1;
}
}
}
let _ = std::fs::remove_dir_all(&fixture.root);
eprintln!("\n{ran} tests run.");
if failed > 0 {
eprintln!("{failed} test(s) failed.");
std::process::exit(1);
}
eprintln!("All tests passed.");
}
#[derive(Clone, Copy, PartialEq)]
enum Status {
Zero,
NonZero,
Eq(i32),
}
struct Case {
name: String,
args: Vec<String>,
cwd: bool,
status: Status,
contains: Vec<String>,
excludes: Vec<String>,
}
impl Case {
fn parse(line: &str) -> Self {
let mut case = Case {
name: String::new(),
args: Vec::new(),
cwd: false,
status: Status::Zero,
contains: Vec::new(),
excludes: Vec::new(),
};
for field in line.split(" | ") {
let (key, value) = field.split_once('=').unwrap_or((field, ""));
match key {
"name" => case.name = value.to_owned(),
"args" => case.args = tokenize(value),
"cwd" => case.cwd = true,
"status" => case.status = parse_status(value),
"out" => case.contains.push(unescape(value)),
"out!" => case.excludes.push(unescape(value)),
other => panic!("{}: unknown field `{other}`", case.name),
}
}
case
}
fn run(&self, fixture: &Fixture) -> Result<(), String> {
let mut command = Command::new(&fixture.bin);
command.args(&self.args);
for (key, value) in &fixture.env {
command.env(key, value);
}
if self.cwd {
command.current_dir(&fixture.root);
}
command.stdin(Stdio::null());
command.stdout(Stdio::piped()).stderr(Stdio::piped());
let output = command
.output()
.map_err(|e| format!("spawn goosedump: {e}"))?;
let code = output.status.code().unwrap_or(-1);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let ok = match self.status {
Status::Zero => code == 0,
Status::NonZero => code != 0,
Status::Eq(expected) => code == expected,
};
if !ok {
return Err(format!("exit {code}; stderr={}", stderr.trim()));
}
for needle in &self.contains {
if !stdout.contains(needle.as_str()) {
return Err(format!("missing `{needle}`; stdout={}", stdout.trim()));
}
}
for needle in &self.excludes {
if stdout.contains(needle.as_str()) {
return Err(format!("unexpected `{needle}`; stdout={}", stdout.trim()));
}
}
Ok(())
}
}
fn parse_status(value: &str) -> Status {
match value {
"0" => Status::Zero,
"!0" => Status::NonZero,
other => Status::Eq(other.parse().expect("status must be 0, !0 or an integer")),
}
}
/// Tokenizes a command line, honoring single and double quotes so a grep
/// pattern that contains spaces survives as a single argument.
fn tokenize(input: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut current: Option<String> = None;
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
match c {
' ' | '\t' => {
if let Some(token) = current.take() {
tokens.push(token);
}
}
'\'' | '"' => {
let quote = c;
let buf = current.get_or_insert_with(String::new);
for inner in chars.by_ref() {
if inner == quote {
break;
}
buf.push(inner);
}
}
_ => current.get_or_insert_with(String::new).push(c),
}
}
if let Some(token) = current {
tokens.push(token);
}
tokens
}
fn unescape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('n') => out.push('\n'),
Some('t') => out.push('\t'),
Some('\\') => out.push('\\'),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
}
out
}
/// Capture stdout of a goosedump invocation against the shared fixture.
fn capture(fixture: &Fixture, args: &[&str]) -> String {
let mut command = Command::new(&fixture.bin);
command.args(args);
for (key, value) in &fixture.env {
command.env(key, value);
}
command.stdin(Stdio::null());
command.stdout(Stdio::piped()).stderr(Stdio::piped());
let output = command.output().expect("spawn goosedump");
String::from_utf8_lossy(&output.stdout).into_owned()
}
/// Round-trip checks: render a context into its own native format, stage that
/// output as a fresh session, read it back, and re-render. The two renders must
/// be identical once the session id differs, which proves the native output
/// re-parses to the same IR (entry uuids, tool-call correlation ids, content).
fn roundtrip_cases(fixture: &Fixture) -> Vec<(String, Result<(), String>)> {
vec![
(
"roundtrip: claude".to_string(),
roundtrip(
fixture,
"claude",
"sesh-claude",
"rt-claude",
&fixture
.root
.join("claude-home/projects/project-claude/rt-claude.jsonl"),
),
),
(
"roundtrip: pi".to_string(),
roundtrip(
fixture,
"pi",
"sesh-pi",
"rt-pi",
&fixture.root.join("pi-sessions/rt-pi.jsonl"),
),
),
]
}
fn roundtrip(
fixture: &Fixture,
provider: &str,
orig_id: &str,
rt_id: &str,
rt_path: &Path,
) -> Result<(), String> {
let original = capture(
fixture,
&[
"show",
&format!("{provider}:{orig_id}"),
"--output-format",
provider,
],
);
if original.trim().is_empty() {
return Err("original render was empty".to_string());
}
if let Some(parent) = rt_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
std::fs::write(rt_path, &original).map_err(|e| e.to_string())?;
let reparsed = capture(
fixture,
&[
"show",
&format!("{provider}:{rt_id}"),
"--output-format",
provider,
],
);
// The session id is the only field that legitimately changes (it is the
// file stem); normalise it before comparing.
let first = original.replace(orig_id, "RTID");
let second = reparsed.replace(rt_id, "RTID");
if first == second {
Ok(())
} else {
Err(format!(
"re-render differs after round-trip:\n--- first ---\n{first}\n--- second ---\n{second}"
))
}
}
/// The staged store tree shared by every case, plus the environment overrides
/// that point each provider's resolver at it.
struct Fixture {
bin: PathBuf,
root: PathBuf,
env: Vec<(String, String)>,
}
impl Fixture {
fn build() -> Self {
let root = std::env::temp_dir().join(format!("goosedump-data-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create fixture root");
build_opencode(&root);
build_crush(&root);
build_pi(&root);
build_codex(&root);
build_goose(&root);
build_claude(&root);
build_gemini(&root);
let env = vec![
("GOOSEDUMP_DATA_DIR".to_owned(), path(&root)),
(
"PI_CODING_AGENT_SESSION_DIR".to_owned(),
path(&root.join("pi-sessions")),
),
("CODEX_HOME".to_owned(), path(&root.join("codex-home"))),
(
"CLAUDE_CONFIG_DIR".to_owned(),
path(&root.join("claude-home")),
),
("GEMINI_DIR".to_owned(), path(&root.join("gemini-home"))),
("XDG_CACHE_HOME".to_owned(), path(&root.join("cache"))),
];
Fixture {
bin: PathBuf::from(env!("CARGO_BIN_EXE_goosedump")),
root,
env,
}
}
}
fn path(p: &Path) -> String {
p.to_string_lossy().into_owned()
}
fn write_lines(path: &Path, lines: &[&str]) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("create parent");
}
let mut body = lines.join("\n");
body.push('\n');
std::fs::write(path, body).expect("write file");
}
fn build_opencode(root: &Path) {
let dir = root.join("opencode");
std::fs::create_dir_all(&dir).expect("create opencode dir");
let conn = Connection::open(dir.join("opencode.db")).expect("open opencode.db");
conn.execute_batch(
"CREATE TABLE message(session_id TEXT, id TEXT, data TEXT, time_created INTEGER);
CREATE TABLE part(session_id TEXT, message_id TEXT, data TEXT, time_created INTEGER);",
)
.expect("opencode schema");
let messages: &[(&str, &str, &str, i64)] = &[
(
"sesh-alpha",
"msg-a",
r#"{"role":"user","id":"entry-alpha","parentID":""}"#,
1000,
),
(
"sesh-alpha",
"msg-b",
r#"{"role":"assistant","id":"entry-bravo","parentID":"entry-alpha"}"#,
2000,
),
(
"sesh-bravo",
"msg-c",
r#"{"role":"user","id":"entry-charlie","parentID":""}"#,
3000,
),
];
for (sid, id, data, time) in messages {
conn.execute(
"INSERT INTO message VALUES(?1, ?2, ?3, ?4)",
params![sid, id, data, time],
)
.expect("insert message");
}
let parts: &[(&str, &str, &str, i64)] = &[
(
"sesh-alpha",
"msg-a",
r#"{"type":"text","text":"search keyword STARTREK-DISCO"}"#,
1000,
),
(
"sesh-alpha",
"msg-b",
r#"{"type":"reasoning","text":"need to read a file"}"#,
2000,
),
(
"sesh-alpha",
"msg-b",
r#"{"type":"text","text":"found STARTREK-DISCO in source"}"#,
2001,
),
(
"sesh-alpha",
"msg-b",
r#"{"type":"tool","name":"read","arguments":{"filePath":"src/main.rs","description":"read tool"}}"#,
2002,
),
(
"sesh-bravo",
"msg-c",
r#"{"type":"text","text":"query about TANGO-SIERRA"}"#,
3000,
),
];
for (sid, mid, data, time) in parts {
conn.execute(
"INSERT INTO part VALUES(?1, ?2, ?3, ?4)",
params![sid, mid, data, time],
)
.expect("insert part");
}
}
fn build_crush(root: &Path) {
let conn = Connection::open(root.join("crush.db")).expect("open crush.db");
conn.execute_batch(
"CREATE TABLE messages(session_id TEXT, role TEXT, parts TEXT, created_at TEXT);",
)
.expect("crush schema");
let rows: &[(&str, &str, &str, &str)] = &[
(
"sesh-crush",
"user",
r#"[{"type":"text","text":"explain ZULU-NOVEMBER"}]"#,
"2026-01-01T00:00:00Z",
),
(
"sesh-crush",
"assistant",
r#"[{"type":"text","text":"ZULU-NOVEMBER is a code pattern"},{"type":"tool_use","name":"grep","input":{"pattern":"ZULU","filePath":"lib.rs"},"result":"lib.rs:9: ZULU-NOVEMBER"}]"#,
"2026-01-01T00:00:01Z",
),
];
for (sid, role, parts, created) in rows {
conn.execute(
"INSERT INTO messages VALUES(?1, ?2, ?3, ?4)",
params![sid, role, parts, created],
)
.expect("insert crush message");
}
std::fs::write(root.join(".crush.json"), r#"{"data_directory":"."}"#)
.expect("write .crush.json");
}
fn build_pi(root: &Path) {
let dir = root.join("pi-sessions");
std::fs::create_dir_all(&dir).expect("create pi dir");
write_lines(
&dir.join("sesh-pi.jsonl"),
&[
r#"{"type":"session","id":"sesh-pi","cwd":"/tmp/project"}"#,
r#"{"type":"message","id":"pi-entry-1","parentId":"","message":{"role":"user","content":[{"type":"text","text":"find TRIPLA-ESPRESSO"}]}}"#,
r#"{"type":"message","id":"pi-entry-2","parentId":"pi-entry-1","message":{"role":"assistant","content":[{"type":"thinking","text":"searching for TRIPLA-ESPRESSO"},{"type":"text","text":"found TRIPLA-ESPRESSO in file"},{"type":"toolCall","name":"bash","arguments":{"command":"grep TRIPLA src/*.rs"}},{"type":"toolCall","name":"todo","arguments":{"action":"update"}}]}}"#,
r#"{"type":"message","id":"pi-entry-3","parentId":"pi-entry-2","message":{"role":"bashExecution","command":"grep TRIPLA src/*.rs","content":[{"type":"text","text":"src/main.rs:123: TRIPLA-ESPRESSO\ntest result: ok. 1 passed"}]}}"#,
r#"{"type":"message","id":"pi-entry-4","parentId":"pi-entry-3","message":{"role":"toolResult","toolName":"todo","content":"Created #99: Review compact parity (pending)\nUpdated #99 (pending -> in_progress)\n1:928|// SPDX-License-Identifier\n.git/\nnode_modules/"}}"#,
r#"{"type":"message","id":"pi-entry-5","parentId":"pi-entry-4","message":{"role":"assistant","content":[{"type":"text","text":"The earlier error is fixed; tests are passing."}]}}"#,
],
);
write_lines(
&dir.join("sesh-pi-merge.jsonl"),
&[
r#"{"type":"session","id":"sesh-pi-merge","cwd":"/tmp/project"}"#,
r#"{"type":"message","id":"pi-merge-1","parentId":"","message":{"role":"assistant","content":[{"type":"text","text":"[Session Goal]\n- Preserve durable context (#prior-goal)\n\n---\n\n[User Preferences]\n- Always run clippy (#prior-pref)\n\n---\n\n[assistant]\nold transcript line"}]}}"#,
],
);
let mut long: Vec<String> = Vec::new();
long.push(r#"{"type":"session","id":"sesh-pi-long","cwd":"/tmp/project"}"#.to_owned());
long.push(long_msg(
1,
"",
"user",
"establish the espresso machine baseline",
));
long.push(long_msg(
2,
"long-1",
"assistant",
"OPENING-ASSISTANT-MARKER reviewing the initial layout",
));
for n in 3..=142 {
long.push(long_msg(
n,
&format!("long-{}", n - 1),
"assistant",
&format!("step {n} inspecting module {n}"),
));
}
long.push(long_msg(
143,
"long-142",
"assistant",
"TAIL-RECENT-MARKER final verification of the pipeline",
));
let long_refs: Vec<&str> = long.iter().map(String::as_str).collect();
write_lines(&dir.join("sesh-pi-long.jsonl"), &long_refs);
}
fn long_msg(idx: u32, parent: &str, role: &str, text: &str) -> String {
format!(
r#"{{"type":"message","id":"long-{idx}","parentId":"{parent}","message":{{"role":"{role}","content":[{{"type":"text","text":"{text}"}}]}}}}"#
)
}
fn build_codex(root: &Path) {
let file = root
.join("codex-home")
.join("sessions")
.join("2026")
.join("06")
.join("11")
.join(format!("rollout-2026-06-11T04-58-00-{CODEX_ID}.jsonl"));
let meta = format!(
r#"{{"timestamp":"2026-06-11T01:58:35.740Z","type":"session_meta","payload":{{"id":"{CODEX_ID}","timestamp":"2026-06-11T01:58:00.570Z","cwd":"/tmp/project","originator":"codex-tui"}}}}"#
);
write_lines(
&file,
&[
&meta,
r#"{"timestamp":"2026-06-11T01:58:35.777Z","type":"response_item","payload":{"type":"message","id":"codex-user-1","role":"user","content":[{"type":"input_text","text":"find DEADBEEF"}]}}"#,
r#"{"timestamp":"2026-06-11T01:58:36.000Z","type":"response_item","payload":{"type":"message","id":"codex-assistant-1","role":"assistant","content":[{"type":"output_text","text":"I found DEADBEEF in source"}]}}"#,
r#"{"timestamp":"2026-06-11T01:58:36.250Z","type":"response_item","payload":{"type":"function_call","call_id":"call-codex-grep","name":"grep","arguments":"{\"pattern\":\"DEADBEEF\",\"path\":\"src/main.rs\"}"}}"#,
r#"{"timestamp":"2026-06-11T01:58:36.300Z","type":"response_item","payload":{"type":"reasoning","id":"codex-reasoning-1","summary":[{"type":"summary_text","text":"consider DEADBEEF search plan"}]}}"#,
r#"{"timestamp":"2026-06-11T01:58:36.350Z","type":"response_item","payload":{"type":"custom_tool_call","call_id":"call-codex-custom","name":"lookup","input":{"query":"DEADBEEF custom"}}}"#,
r#"{"timestamp":"2026-06-11T01:58:36.400Z","type":"response_item","payload":{"type":"local_shell_call","call_id":"call-codex-shell","action":{"command":"grep DEADBEEF src/main.rs"}}}"#,
r#"{"timestamp":"2026-06-11T01:58:36.500Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call-codex-grep","output":"src/main.rs:7: DEADBEEF"}}"#,
],
);
}
fn build_goose(root: &Path) {
let dir = root.join("goose").join("sessions");
std::fs::create_dir_all(&dir).expect("create goose dir");
let conn = Connection::open(dir.join("sessions.db")).expect("open sessions.db");
conn.execute_batch(
"CREATE TABLE sessions(id TEXT PRIMARY KEY, name TEXT, working_dir TEXT, updated_at TIMESTAMP);
CREATE TABLE messages(id INTEGER PRIMARY KEY AUTOINCREMENT, message_id TEXT, session_id TEXT, role TEXT, content_json TEXT, created_timestamp INTEGER);",
)
.expect("goose schema");
conn.execute(
"INSERT INTO sessions VALUES(?1, ?2, ?3, ?4)",
params![
"sesh-goose",
"Review Session",
"/tmp/project",
"2026-06-10T00:00:00Z"
],
)
.expect("insert goose session");
let rows: &[(i64, &str, &str, &str, i64)] = &[
(
1,
"msg-1",
"user",
r#"[{"type":"text","text":"find YANKEE-ECHO"}]"#,
1000,
),
(
2,
"msg-2",
"assistant",
r#"[{"type":"thinking","thinking":"searching for YANKEE-ECHO","signature":""},{"type":"text","text":"let me search for that"}]"#,
2000,
),
(
3,
"msg-3",
"assistant",
r#"[{"type":"toolRequest","id":"call_00","toolCall":{"status":"success","value":{"name":"grep","arguments":{"pattern":"YANKEE"}}}}]"#,
3000,
),
(
4,
"msg-4",
"user",
r#"[{"type":"toolResponse","id":"call_00","toolResult":{"status":"success","value":{"name":"grep","content":[{"type":"text","text":"src/main.rs:42: YANKEE-ECHO"}]}}}]"#,
4000,
),
];
for (id, mid, role, content, time) in rows {
conn.execute(
"INSERT INTO messages VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
params![id, mid, "sesh-goose", role, content, time],
)
.expect("insert goose message");
}
}
fn build_claude(root: &Path) {
let file = root
.join("claude-home")
.join("projects")
.join("project-claude")
.join("sesh-claude.jsonl");
write_lines(
&file,
&[
r#"{"type":"mode","mode":"normal","sessionId":"sesh-claude"}"#,
r#"{"type":"user","uuid":"claude-user-1","parentUuid":null,"sessionId":"sesh-claude","cwd":"/tmp/project","gitBranch":"main","timestamp":"2026-06-11T01:58:35.777Z","message":{"role":"user","content":"find SLOP-STARS"}}"#,
r#"{"type":"assistant","uuid":"claude-asst-1","parentUuid":"claude-user-1","sessionId":"sesh-claude","cwd":"/tmp/project","timestamp":"2026-06-11T01:58:36.000Z","message":{"role":"assistant","model":"claude-opus-4-8","content":[{"type":"thinking","thinking":"looking for SLOP-STARS","signature":"sig"},{"type":"text","text":"I found SLOP-STARS in source"},{"type":"tool_use","id":"toolu_grep","name":"Bash","input":{"command":"grep SLOP src/*.rs"}}]}}"#,
r#"{"type":"user","uuid":"claude-tool-1","parentUuid":"claude-asst-1","sessionId":"sesh-claude","cwd":"/tmp/project","timestamp":"2026-06-11T01:58:36.100Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_grep","content":[{"type":"text","text":"src/main.rs:7: SLOP-STARS"}]}]}}"#,
r#"{"type":"assistant","uuid":"claude-asst-2","parentUuid":"claude-tool-1","sessionId":"sesh-claude","cwd":"/tmp/project","timestamp":"2026-06-11T01:58:36.200Z","message":{"role":"assistant","model":"claude-opus-4-8","content":[{"type":"tool_use","id":"toolu_read","name":"Read","input":{"file_path":"/nope"}}]}}"#,
r#"{"type":"user","uuid":"claude-tool-2","parentUuid":"claude-asst-2","sessionId":"sesh-claude","cwd":"/tmp/project","timestamp":"2026-06-11T01:58:36.300Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_read","content":"file not found","is_error":true}]}}"#,
],
);
}
fn build_gemini(root: &Path) {
let file = root
.join("gemini-home")
.join("tmp")
.join("project-gemini")
.join("chats")
.join("session-gemini-1.json");
if let Some(parent) = file.parent() {
std::fs::create_dir_all(parent).expect("create gemini dir");
}
std::fs::write(
&file,
r#"{"sessionId":"11111111-1111-1111-1111-111111111111","projectHash":"project-gemini","startTime":"2026-06-11T02:00:00.000Z","lastUpdated":"2026-06-11T02:01:00.000Z","messages":[{"id":"gem-user-1","timestamp":"2026-06-11T02:00:00.000Z","type":"user","content":"find SLOP-STARS"},{"id":"gem-asst-1","timestamp":"2026-06-11T02:00:01.000Z","type":"gemini","model":"gemini-2.5-pro","thoughts":[{"subject":"Searching","description":"looking for SLOP-STARS","timestamp":"2026-06-11T02:00:01.000Z"}],"content":"I found SLOP-STARS in source","toolCalls":[{"id":"tc-grep","name":"grep_search","args":{"pattern":"SLOP"},"status":"success","timestamp":"2026-06-11T02:00:01.500Z","result":[{"functionResponse":{"id":"tc-grep","name":"grep_search","response":{"output":"src/main.rs:7: SLOP-STARS"}}}]},{"id":"tc-read","name":"read_file","args":{"path":"/nope"},"status":"error","timestamp":"2026-06-11T02:00:01.700Z","result":[{"functionResponse":{"id":"tc-read","name":"read_file","response":{"error":"file not found"}}}]}]}]}"#,
)
.expect("write gemini file");
}