pub(crate) mod archive;
pub(crate) mod export;
mod images;
pub mod index;
mod migrate;
pub(crate) mod notices;
mod read;
mod transcript;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub(crate) use archive::{IncludeSet, run_backfill, save_session};
pub(crate) use export::run as run_export;
pub(crate) use export::{ExportIncludeSet, ExportRequest, Format, Selector};
pub(crate) use migrate::migrate_archives;
pub(crate) use read::{list_sessions, read_session, search_archives};
pub(crate) const MANIFEST_WRITE_VERSION: u32 = 5;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
pub version: u32,
pub session_id: String,
pub archived_at: DateTime<Utc>,
pub session_start: DateTime<Utc>,
pub session_end: DateTime<Utc>,
pub project_path: Option<String>,
pub message_count: usize,
pub agent_count: usize,
pub agents: Vec<AgentInfo>,
pub size_bytes: u64,
pub checksum: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_count: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub images: Option<Vec<ImageInfo>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub has_clean_transcript: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub assistant_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_output_count: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp_log_count: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub history_lines: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_breakdown: Option<SourceBreakdown>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct SourceBreakdown {
#[serde(default)]
pub session_jsonl_bytes: u64,
#[serde(default)]
pub agents_bytes: u64,
#[serde(default)]
pub images_bytes: u64,
#[serde(default)]
pub mcp_bytes: u64,
#[serde(default)]
pub tool_output_bytes: u64,
#[serde(default)]
pub history_bytes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentInfo {
pub id: String,
pub file: String,
pub messages: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageInfo {
pub hash: String,
pub media_type: String,
pub size_bytes: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub original_tool_use_id: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct ArchiveEntry {
pub(crate) dir_name: String,
pub(crate) short_id: String,
pub(crate) incremental: u32,
pub(crate) manifest: Manifest,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn manifest_deserialize_without_speaker_names() {
let json = r#"{
"version": 2,
"session_id": "abc123",
"archived_at": "2026-01-01T00:00:00Z",
"session_start": "2026-01-01T00:00:00Z",
"session_end": "2026-01-01T00:00:00Z",
"project_path": null,
"message_count": 10,
"agent_count": 0,
"agents": [],
"size_bytes": 1024,
"checksum": "sha256:aaa"
}"#;
let manifest: Manifest = serde_json::from_str(json).unwrap();
assert_eq!(manifest.user_name, None);
assert_eq!(manifest.assistant_name, None);
assert_eq!(manifest.has_clean_transcript, None);
assert_eq!(manifest.image_count, None);
assert_eq!(manifest.tool_output_count, None);
assert_eq!(manifest.mcp_log_count, None);
assert_eq!(manifest.history_lines, None);
assert_eq!(manifest.source_breakdown, None);
}
#[test]
fn manifest_v2_round_trips_through_v5() {
let v2_json = r#"{
"version": 2,
"session_id": "abc123",
"archived_at": "2026-01-01T00:00:00Z",
"session_start": "2026-01-01T00:00:00Z",
"session_end": "2026-01-01T00:00:00Z",
"project_path": "/home/test/project",
"message_count": 42,
"agent_count": 1,
"agents": [{"id": "ag-1", "file": "agents/agent-1.jsonl", "messages": 7}],
"size_bytes": 99999,
"checksum": "sha256:deadbeef",
"image_count": 3,
"images": [{"hash": "h1", "media_type": "image/png", "size_bytes": 100}]
}"#;
let mut manifest: Manifest = serde_json::from_str(v2_json).unwrap();
assert_eq!(manifest.version, 2);
assert_eq!(manifest.image_count, Some(3));
assert_eq!(manifest.tool_output_count, None);
assert_eq!(manifest.source_breakdown, None);
manifest.version = MANIFEST_WRITE_VERSION;
let v5_json = serde_json::to_string_pretty(&manifest).unwrap();
let round_tripped: Manifest = serde_json::from_str(&v5_json).unwrap();
assert_eq!(round_tripped.version, MANIFEST_WRITE_VERSION);
assert_eq!(round_tripped.session_id, "abc123");
assert_eq!(round_tripped.message_count, 42);
assert_eq!(round_tripped.image_count, Some(3));
assert_eq!(round_tripped.tool_output_count, None);
assert_eq!(round_tripped.mcp_log_count, None);
assert_eq!(round_tripped.history_lines, None);
assert_eq!(round_tripped.source_breakdown, None);
assert!(!v5_json.contains("tool_output_count"));
assert!(!v5_json.contains("mcp_log_count"));
assert!(!v5_json.contains("history_lines"));
assert!(!v5_json.contains("source_breakdown"));
}
#[test]
fn manifest_v5_with_new_fields_round_trips() {
let breakdown = SourceBreakdown {
session_jsonl_bytes: 1024,
agents_bytes: 512,
images_bytes: 2048,
mcp_bytes: 256,
tool_output_bytes: 128,
history_bytes: 64,
};
let manifest = Manifest {
version: MANIFEST_WRITE_VERSION,
session_id: "v5-test".to_string(),
archived_at: "2026-04-29T00:00:00Z".parse().unwrap(),
session_start: "2026-04-29T00:00:00Z".parse().unwrap(),
session_end: "2026-04-29T01:00:00Z".parse().unwrap(),
project_path: None,
message_count: 0,
agent_count: 0,
agents: vec![],
size_bytes: 0,
checksum: "sha256:zero".to_string(),
image_count: None,
images: None,
has_clean_transcript: None,
user_name: None,
assistant_name: None,
tool_output_count: Some(5),
mcp_log_count: Some(2),
history_lines: Some(17),
source_breakdown: Some(breakdown.clone()),
};
let s = serde_json::to_string(&manifest).unwrap();
let back: Manifest = serde_json::from_str(&s).unwrap();
assert_eq!(back.tool_output_count, Some(5));
assert_eq!(back.mcp_log_count, Some(2));
assert_eq!(back.history_lines, Some(17));
assert_eq!(back.source_breakdown, Some(breakdown));
}
#[test]
fn manifest_write_version_is_five() {
assert_eq!(MANIFEST_WRITE_VERSION, 5);
}
#[test]
fn archive_source_prefers_clean_md_over_jsonl() {
let dir = tempfile::tempdir().unwrap();
let md_path = dir.path().join("conversation.md");
let jsonl_path = dir.path().join("session.jsonl");
std::fs::write(&md_path, "# Conversation\n").unwrap();
let result = read::archive_source(dir.path()).unwrap();
assert_eq!(result, md_path, "should prefer conversation.md");
std::fs::write(&jsonl_path, "{}\n").unwrap();
let result = read::archive_source(dir.path()).unwrap();
assert_eq!(
result, md_path,
"should prefer conversation.md when both exist"
);
std::fs::remove_file(&md_path).unwrap();
let result = read::archive_source(dir.path()).unwrap();
assert_eq!(result, jsonl_path, "should fall back to session.jsonl");
std::fs::remove_file(&jsonl_path).unwrap();
assert!(
read::archive_source(dir.path()).is_none(),
"should return None when no transcript exists"
);
}
#[test]
fn archive_source_real_archives_are_searchable() {
let codex_dir = match archive::get_codex_dir() {
Ok(d) => d,
Err(_) => return, };
if !codex_dir.exists() {
return; }
let archives = match archive::collect_archives(&codex_dir) {
Ok(a) => a,
Err(_) => return,
};
if archives.is_empty() {
return; }
let searchable = archives.iter().filter(|a| {
let archive_dir = codex_dir.join(&a.dir_name);
read::archive_source(&archive_dir).is_some()
});
assert!(
searchable.count() > 0,
"no archives are searchable — archive_source found neither \
conversation.md nor session.jsonl in any of the {} archive(s) \
under {:?}. search_archives would return zero results.",
archives.len(),
codex_dir
);
}
}