use std::path::{Path, PathBuf};
use serde_json::Value;
use supercode_harness::audit::{audit_dir, Corpus};
use supercode_harness::session::{
opencode_sqlite_primary_id, opencode_sqlite_session_ids, opencode_sqlite_store_stats, Session,
};
use supercode_harness::Role;
const SESSION_A: &str = "ses_fixtureAAAAAAAAAAAAAAA1";
const SESSION_B: &str = "ses_fixtureBBBBBBBBBBBBBBB1";
fn fixture_db() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/opencode_fixture/opencode.db")
}
fn fixture_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/opencode_fixture")
}
#[test]
fn store_stats_report_real_counts() {
let stats = opencode_sqlite_store_stats(&fixture_db()).expect("store stats must not fail");
assert_eq!(stats.sessions, 2, "session A + session B");
assert_eq!(stats.messages, 4, "2 messages/session x 2 sessions");
assert_eq!(
stats.parts, 9,
"4 (user A) + 3 (asst A) + 1 (user B) + 1 (asst B)"
);
assert_eq!(stats.todos, 1, "one todo row, session A only");
}
#[test]
fn multi_session_store_lists_both_sessions_and_picks_the_newer_as_primary() {
let ids = opencode_sqlite_session_ids(&fixture_db()).expect("must list session ids");
assert_eq!(ids.len(), 2);
assert!(ids.contains(&SESSION_A.to_string()));
assert!(ids.contains(&SESSION_B.to_string()));
let primary = opencode_sqlite_primary_id(&fixture_db()).expect("must pick a primary session");
assert_eq!(
primary, SESSION_A,
"session A is newer (time_created/time_updated) — must be auto-picked"
);
}
#[test]
fn session_a_loads_with_every_nullable_column_actually_null() {
let session =
Session::from_opencode_sqlite(&fixture_db(), Some(SESSION_A)).expect("must load cleanly");
assert_eq!(session.meta.session_id.as_deref(), Some(SESSION_A));
let info = &session.meta.opencode_headers[0];
assert!(
info.get("workspaceID").is_none_or(Value::is_null),
"workspace_id must load as null, not crash"
);
assert!(info.get("path").is_none_or(Value::is_null));
assert!(info.get("agent").is_none_or(Value::is_null));
assert_eq!(
info.get("model").cloned().unwrap_or(Value::Null),
Value::Null,
"model column was NULL — opencode_json_col(None) must yield Null, not error"
);
assert_eq!(info.get("cost").and_then(Value::as_f64), Some(0.0421));
assert_eq!(
info.get("tokens")
.and_then(|t| t.get("input"))
.and_then(Value::as_i64),
Some(120)
);
}
#[test]
fn session_b_secondary_loads_with_model_and_agent_present() {
let session =
Session::from_opencode_sqlite(&fixture_db(), Some(SESSION_B)).expect("must load cleanly");
let info = &session.meta.opencode_headers[0];
assert_eq!(info.get("agent").and_then(Value::as_str), Some("build"));
assert!(
info.get("model").is_some_and(|m| !m.is_null()),
"session B's model column is non-NULL and must decode"
);
}
#[test]
fn revert_column_files_field_survives_raw_verbatim() {
let session =
Session::from_opencode_sqlite(&fixture_db(), Some(SESSION_A)).expect("must load cleanly");
let info = &session.meta.opencode_headers[0];
let files = info
.get("revert")
.and_then(|r| r.get("files"))
.and_then(Value::as_array)
.expect("revert.files must survive in the raw envelope (S9c)");
assert_eq!(files.len(), 1);
assert_eq!(
files[0].get("path").and_then(Value::as_str),
Some("src/a.txt")
);
}
#[test]
fn message_and_part_envelope_lines_carry_row_timestamps() {
let session =
Session::from_opencode_sqlite(&fixture_db(), Some(SESSION_A)).expect("must load cleanly");
let mut saw_message_ts = false;
let mut saw_part_ts = false;
for line in &session.raw {
let env: Value = serde_json::from_str(line).unwrap();
let Some(key) = env.get("key").and_then(Value::as_array) else {
continue;
};
let kind = key.first().and_then(Value::as_str).unwrap_or("");
let value = env.get("value").cloned().unwrap_or(Value::Null);
match kind {
"message" => {
assert!(
value.get("time_created").and_then(Value::as_i64).is_some(),
"message envelope must carry time_created (D2): {value}"
);
assert!(
value.get("time_updated").and_then(Value::as_i64).is_some(),
"message envelope must carry time_updated (D2): {value}"
);
saw_message_ts = true;
}
"part" => {
assert!(
value.get("time_created").and_then(Value::as_i64).is_some(),
"part envelope must carry time_created (D2): {value}"
);
assert!(
value.get("time_updated").and_then(Value::as_i64).is_some(),
"part envelope must carry time_updated (D2): {value}"
);
saw_part_ts = true;
}
_ => {}
}
}
assert!(
saw_message_ts,
"must have scanned at least one message line"
);
assert!(saw_part_ts, "must have scanned at least one part line");
}
#[test]
fn session_diff_sidecar_is_read_into_headers_and_raw() {
let session =
Session::from_opencode_sqlite(&fixture_db(), Some(SESSION_A)).expect("must load cleanly");
let found = session.meta.opencode_headers.iter().any(|h| {
h.get("key")
.and_then(Value::as_array)
.and_then(|k| k.first())
.and_then(Value::as_str)
== Some("session_diff")
});
assert!(
found,
"session_diff sidecar (D3) must appear in opencode_headers: {:?}",
session.meta.opencode_headers
);
let raw_found = session.raw.iter().any(|line| {
serde_json::from_str::<Value>(line)
.ok()
.and_then(|v| {
v.get("key")
.and_then(Value::as_array)
.and_then(|k| k.first())
.cloned()
})
.and_then(|k| k.as_str().map(|s| s == "session_diff"))
.unwrap_or(false)
});
assert!(
raw_found,
"session_diff must also appear in raw envelope lines"
);
let session_b =
Session::from_opencode_sqlite(&fixture_db(), Some(SESSION_B)).expect("must load cleanly");
let found_b = session_b.meta.opencode_headers.iter().any(|h| {
h.get("key")
.and_then(Value::as_array)
.and_then(|k| k.first())
== Some(&Value::from("session_diff"))
});
assert!(
!found_b,
"session B has no session_diff sidecar — must not fabricate one"
);
}
#[test]
fn sqlite_sourced_session_is_never_claimed_verbatim() {
let session =
Session::from_opencode_sqlite(&fixture_db(), Some(SESSION_A)).expect("must load cleanly");
assert!(
!session.raw_is_verbatim,
"a SQLite `.db` source has no verbatim line-oriented form — \
`convert opencode.db --to opencode` must not claim byte-identical (D4)"
);
}
#[test]
fn canonical_messages_split_tool_call_and_result_for_both_outcomes() {
let session =
Session::from_opencode_sqlite(&fixture_db(), Some(SESSION_A)).expect("must load cleanly");
let user = session
.messages
.iter()
.find(|m| m.role == Role::User)
.expect("must have a user message");
let full_text = user.content.clone().unwrap_or_default()
+ &user
.content_parts
.as_ref()
.map(|parts| {
parts
.iter()
.filter(|p| p.get("type").and_then(Value::as_str) == Some("text"))
.filter_map(|p| p.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("")
})
.unwrap_or_default();
assert!(full_text.contains("Please check this file"));
assert!(
!full_text.contains("internal scratch note"),
"an ignored:true text part must never be replayed: {full_text:?}"
);
let images = user
.content_parts
.as_ref()
.map(|parts| {
parts
.iter()
.filter(|p| p.get("type").and_then(Value::as_str) == Some("image_url"))
.count()
})
.unwrap_or(0);
assert_eq!(
images, 1,
"only the data:-URI image file part becomes image_url"
);
let non_image_urls = user
.content_parts
.as_ref()
.map(|parts| {
parts
.iter()
.filter(|p| {
p.get("image_url")
.and_then(|iu| iu.get("url"))
.and_then(Value::as_str)
.is_some_and(|u| u.starts_with("https:"))
})
.count()
})
.unwrap_or(0);
assert_eq!(
non_image_urls, 0,
"an https: file part must never become an image_url"
);
let tool_messages: Vec<_> = session
.messages
.iter()
.filter(|m| m.role == Role::Tool)
.collect();
assert_eq!(
tool_messages.len(),
2,
"one completed + one errored tool result"
);
let has_completed = tool_messages.iter().any(|m| {
m.content
.as_deref()
.is_some_and(|c| c.contains("hello world"))
});
let has_error = tool_messages.iter().any(|m| {
m.content
.as_deref()
.is_some_and(|c| c.contains("permission denied"))
});
assert!(
has_completed,
"completed tool output must survive: {tool_messages:?}"
);
assert!(
has_error,
"errored tool output must survive: {tool_messages:?}"
);
}
#[test]
fn audit_classifies_non_image_file_and_ignored_text_as_dropped_not_normalized() {
let report = audit_dir(&fixture_dir(), Corpus::OpenCode, None);
let records = &report.records;
let (residue_cov, residue_tally) = records
.get("part/file:residue")
.expect("non-image file part must be tallied separately from the canonicalized case");
assert_eq!(
format!("{residue_cov:?}"),
"Dropped",
"an https: file part is raw-only residue, not replayed — must not be Normalized"
);
assert!(residue_tally.count >= 1);
let (image_cov, _) = records
.get("part/file")
.expect("the data:-URI image file part IS canonicalized");
assert_eq!(format!("{image_cov:?}"), "Normalized");
let (ignored_cov, _) = records
.get("part/text:ignored")
.expect("an ignored:true text part must be tallied separately");
assert_eq!(format!("{ignored_cov:?}"), "Dropped");
let (plain_text_cov, _) = records
.get("part/text")
.expect("non-ignored text part tallied");
assert_eq!(format!("{plain_text_cov:?}"), "Normalized");
assert!(
records.contains_key("session_diff"),
"session_diff must be enumerated by audit: {:?}",
records.keys().collect::<Vec<_>>()
);
assert!(
report.tools.contains_key("bash"),
"tools: {:?}",
report.tools
);
assert!(
report.tools.contains_key("edit"),
"tools: {:?}",
report.tools
);
}
#[test]
fn malformed_db_path_fails_with_clear_diagnostic_not_a_panic() {
let dir = std::env::temp_dir().join(format!("sc-opencode-malformed-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let bogus = dir.join("opencode.db");
std::fs::write(&bogus, b"SQLite format 3\0not a real database body").unwrap();
let result = Session::from_opencode_sqlite(&bogus, None);
assert!(
result.is_err(),
"a corrupt .db must fail cleanly, not silently succeed"
);
let msg = result.unwrap_err().to_string();
assert!(
!msg.to_lowercase().contains("utf-8"),
"diagnostic must be SQLite-aware, not a generic UTF-8 read error: {msg}"
);
let _ = std::fs::remove_file(&bogus);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn lean_v1_2_15_session_table_without_newer_columns_still_loads() {
let dir = std::env::temp_dir().join(format!("sc-opencode-lean-v1215-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let db_path = dir.join("opencode.db");
let _ = std::fs::remove_file(&db_path);
let conn = rusqlite::Connection::open(&db_path).expect("open lean fixture db");
conn.execute_batch(
"
CREATE TABLE session (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
parent_id TEXT,
slug TEXT NOT NULL,
directory TEXT NOT NULL,
title TEXT NOT NULL,
version TEXT NOT NULL,
share_url TEXT,
summary_additions INTEGER,
summary_deletions INTEGER,
summary_files INTEGER,
summary_diffs TEXT,
revert TEXT,
permission TEXT,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
time_compacting INTEGER,
time_archived INTEGER
);
CREATE TABLE message (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
data TEXT NOT NULL
);
CREATE TABLE part (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL,
session_id TEXT NOT NULL,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
data TEXT NOT NULL
);
CREATE TABLE todo (
session_id TEXT NOT NULL,
content TEXT NOT NULL,
status TEXT NOT NULL,
priority TEXT NOT NULL,
position INTEGER NOT NULL,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL
);
",
)
.expect("create lean v1.2.15-style schema");
let session_id = "ses_leanV1215Fixture0000001";
let msg_id = "msg_leanV1215Fixture0000001";
let part_id = "prt_leanV1215Fixture0000001";
let t0: i64 = 1_750_000_000_000;
conn.execute(
"INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, \
share_url, summary_additions, summary_deletions, summary_files, summary_diffs, \
revert, permission, time_created, time_updated, time_compacting, time_archived) \
VALUES (?1, ?2, NULL, ?3, ?4, ?5, ?6, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?7, ?8, NULL, NULL)",
rusqlite::params![
session_id,
"prj_leanV1215Fixture0000001",
"lean-v1215-session",
"/tmp/lean-v1215-project",
"Lean v1.2.15 Fixture Session",
"1.0.0",
t0,
t0 + 1_000,
],
)
.expect("insert lean session row");
let message_data = serde_json::json!({
"role": "user",
"time": {"created": t0},
});
conn.execute(
"INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![msg_id, session_id, t0, t0 + 100, message_data.to_string()],
)
.expect("insert lean message row");
let part_data = serde_json::json!({"type": "text", "text": "hi"});
conn.execute(
"INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![part_id, msg_id, session_id, t0, t0, part_data.to_string()],
)
.expect("insert lean part row");
conn.close().expect("close lean fixture db");
let session = Session::from_opencode_sqlite(&db_path, None)
.expect("a real v1.2.15-style store (missing newer session columns) must still load");
assert_eq!(session.meta.session_id.as_deref(), Some(session_id));
assert_eq!(session.messages.len(), 1, "must have the one user message");
let user = &session.messages[0];
assert_eq!(user.role, supercode_harness::Role::User);
assert_eq!(user.content.as_deref(), Some("hi"));
let _ = std::fs::remove_file(&db_path);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn load_on_a_data_root_directory_resolves_the_sqlite_store() {
let dir = std::env::temp_dir().join(format!("sc-opencode-dataroot-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let db_path = dir.join("opencode.db");
let _ = std::fs::remove_file(&db_path);
let conn = rusqlite::Connection::open(&db_path).expect("open lean fixture db");
conn.execute_batch(
"
CREATE TABLE session (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
parent_id TEXT,
slug TEXT NOT NULL,
directory TEXT NOT NULL,
title TEXT NOT NULL,
version TEXT NOT NULL,
share_url TEXT,
summary_additions INTEGER,
summary_deletions INTEGER,
summary_files INTEGER,
summary_diffs TEXT,
revert TEXT,
permission TEXT,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
time_compacting INTEGER,
time_archived INTEGER
);
CREATE TABLE message (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
data TEXT NOT NULL
);
CREATE TABLE part (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL,
session_id TEXT NOT NULL,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL,
data TEXT NOT NULL
);
CREATE TABLE todo (
session_id TEXT NOT NULL,
content TEXT NOT NULL,
status TEXT NOT NULL,
priority TEXT NOT NULL,
position INTEGER NOT NULL,
time_created INTEGER NOT NULL,
time_updated INTEGER NOT NULL
);
",
)
.expect("create lean schema");
let session_id = "ses_dataRootDirFixture000001";
let msg_id = "msg_dataRootDirFixture000001";
let part_id = "prt_dataRootDirFixture000001";
let t0: i64 = 1_750_000_000_000;
conn.execute(
"INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, \
share_url, summary_additions, summary_deletions, summary_files, summary_diffs, \
revert, permission, time_created, time_updated, time_compacting, time_archived) \
VALUES (?1, ?2, NULL, ?3, ?4, ?5, ?6, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?7, ?8, NULL, NULL)",
rusqlite::params![
session_id,
"prj_dataRootDirFixture000001",
"data-root-dir-session",
"/tmp/data-root-dir-project",
"Data Root Dir Fixture Session",
"1.0.0",
t0,
t0 + 1_000,
],
)
.expect("insert session row");
let message_data = serde_json::json!({
"role": "user",
"time": {"created": t0},
});
conn.execute(
"INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![msg_id, session_id, t0, t0 + 100, message_data.to_string()],
)
.expect("insert message row");
let part_data = serde_json::json!({"type": "text", "text": "hello from a data-root dir"});
conn.execute(
"INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
rusqlite::params![part_id, msg_id, session_id, t0, t0, part_data.to_string()],
)
.expect("insert part row");
conn.close().expect("close fixture db");
let session = Session::load(&dir)
.expect("Session::load on an OpenCode data-root directory must resolve the store");
assert_eq!(session.meta.session_id.as_deref(), Some(session_id));
assert_eq!(session.messages.len(), 1);
assert_eq!(
session.messages[0].content.as_deref(),
Some("hello from a data-root dir")
);
let _ = std::fs::remove_file(&db_path);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn load_on_an_empty_directory_gives_a_clear_error_not_a_raw_errno() {
let dir = std::env::temp_dir().join(format!("sc-opencode-emptydir-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let result = Session::load(&dir);
assert!(
result.is_err(),
"an empty directory with no session file or OpenCode store must fail"
);
let msg = result.unwrap_err().to_string();
assert!(
!msg.to_lowercase().contains("os error 21"),
"must not surface the raw os-error-21 errno text: {msg}"
);
assert!(
msg.contains("no session file or OpenCode store"),
"must explain what's missing: {msg}"
);
assert!(
msg.contains(dir.to_string_lossy().as_ref()),
"must name the path: {msg}"
);
let _ = std::fs::remove_dir(&dir);
}