use anyhow::Result;
use std::fs;
use std::path::{Path, PathBuf};
use super::{ArchiveEntry, Manifest};
mod backfill;
mod include;
mod paths;
mod sources;
mod write;
pub(crate) use backfill::run_backfill;
pub(crate) use include::IncludeSet;
pub(crate) use paths::{get_base_archive_name, parse_archive_name};
#[derive(Debug, Clone)]
pub enum ArchiveRequest {
Single(PathBuf),
All,
}
#[derive(Debug, Clone)]
pub struct ArchiveOptions {
pub clean: bool,
pub include: IncludeSet,
pub include_agents_in_clean_md: bool,
}
impl Default for ArchiveOptions {
fn default() -> Self {
Self {
clean: false,
include: IncludeSet::status_quo(),
include_agents_in_clean_md: false,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ArchiveResult {
pub archived_count: usize,
pub skipped_count: usize,
pub archive_paths: Vec<PathBuf>,
}
pub fn run(request: ArchiveRequest, options: ArchiveOptions) -> Result<ArchiveResult> {
let mut result = ArchiveResult::default();
match request {
ArchiveRequest::Single(path) => {
let archive_dir = write::archive_session(
&path,
options.clean,
options.include_agents_in_clean_md,
&options.include,
)?;
result.archived_count = 1;
result.archive_paths.push(archive_dir);
}
ArchiveRequest::All => {
result = write::save_all_sessions(
options.clean,
options.include_agents_in_clean_md,
&options.include,
)?;
}
}
if let Err(e) = rebuild_project_index() {
eprintln!("warning: by-project index rebuild failed: {e}");
}
Ok(result)
}
fn rebuild_project_index() -> Result<()> {
let mut idx = super::index::ProjectIndex::open()?;
idx.rebuild_from_manifests()?;
Ok(())
}
pub(crate) fn archive_session(
session_path: Option<String>,
all: bool,
clean: bool,
include_agents: bool,
include: IncludeSet,
) -> Result<()> {
let request = if all {
ArchiveRequest::All
} else {
let path = resolve_session_path(session_path)?;
ArchiveRequest::Single(path)
};
let options = ArchiveOptions {
clean,
include,
include_agents_in_clean_md: include_agents,
};
run(request, options)?;
Ok(())
}
fn resolve_session_path(path: Option<String>) -> Result<PathBuf> {
if let Some(p) = path {
Ok(PathBuf::from(p))
} else {
crate::session::find_most_recent_session()
}
}
pub(super) fn collect_archives(codex_dir: &Path) -> Result<Vec<ArchiveEntry>> {
let mut archives = Vec::new();
for entry in fs::read_dir(codex_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let manifest_path = path.join("manifest.json");
if !manifest_path.exists() {
continue;
}
let manifest_content = fs::read_to_string(&manifest_path)?;
let manifest: Manifest = serde_json::from_str(&manifest_content)?;
let dir_name = path.file_name().unwrap().to_string_lossy().to_string();
let (short_id, incremental) = parse_archive_name(&dir_name);
archives.push(ArchiveEntry {
dir_name,
short_id,
incremental,
manifest,
});
}
Ok(archives)
}
pub(super) fn get_codex_dir() -> Result<PathBuf> {
Ok(crate::paths::codex_dir())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::codex::Manifest;
use serial_test::serial;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn fixture_session_jsonl() -> String {
concat!(
r#"{"role":"user","content":"hello","timestamp":"2026-04-29T10:00:00Z"}"#,
"\n",
r#"{"role":"assistant","content":"hi there","timestamp":"2026-04-29T10:00:30Z"}"#,
"\n",
r#"{"role":"user","content":"thanks","timestamp":"2026-04-29T10:01:00Z"}"#,
"\n",
)
.to_string()
}
fn normalize(value: &mut serde_json::Value) {
use serde_json::Value;
if let Value::Object(map) = value {
for key in ["archived_at", "version"] {
if map.contains_key(key) {
map.insert(key.to_string(), Value::String("__SENTINEL__".to_string()));
}
}
for key in ["user_name", "assistant_name"] {
if let Some(v) = map.get_mut(key)
&& !v.is_null()
{
*v = Value::String("__SENTINEL__".to_string());
}
}
if let Some(v) = map.get_mut("size_bytes")
&& v.is_number()
{
*v = Value::String("__SENTINEL_NUM__".to_string());
}
}
}
fn assert_manifest_matches_golden(manifest_text: &str, golden_path: &Path) {
let mut got: serde_json::Value =
serde_json::from_str(manifest_text).expect("manifest is not valid JSON");
normalize(&mut got);
let regen_env = std::env::var("MX_UPDATE_GOLDENS").is_ok();
if regen_env || !golden_path.exists() {
let pretty = serde_json::to_string_pretty(&got).unwrap();
std::fs::create_dir_all(golden_path.parent().unwrap()).unwrap();
std::fs::write(golden_path, &pretty).unwrap();
if !regen_env {
eprintln!(
"note: created golden {} on first run",
golden_path.display()
);
return;
}
}
let want_text = std::fs::read_to_string(golden_path)
.unwrap_or_else(|e| panic!("missing golden {}: {e}", golden_path.display()));
let want: serde_json::Value =
serde_json::from_str(&want_text).expect("golden is not valid JSON");
assert_eq!(
got,
want,
"manifest structure drifted from golden {}\n\
tip: re-run with MX_UPDATE_GOLDENS=1 to refresh, then audit the diff before committing.",
golden_path.display()
);
let got_pretty = serde_json::to_string_pretty(&got).unwrap();
let want_pretty = serde_json::to_string_pretty(&want).unwrap();
assert_eq!(
got_pretty,
want_pretty,
"manifest pretty-print bytes drifted from golden {}",
golden_path.display()
);
}
fn golden_path(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("fixtures")
.join("manifest-golden")
.join(name)
}
fn archive_and_read_manifest(clean: bool) -> (String, Manifest) {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let codex_dir = tmp.path().join("codex");
std::fs::create_dir_all(&codex_dir).unwrap();
let session_dir = tmp.path().join("project-slug");
std::fs::create_dir_all(&session_dir).unwrap();
let session_path = session_dir.join("c3744b8d-test.jsonl");
std::fs::write(&session_path, fixture_session_jsonl()).unwrap();
let prev = std::env::var("MX_CODEX_PATH").ok();
unsafe {
std::env::set_var("MX_CODEX_PATH", &codex_dir);
}
let options = ArchiveOptions {
clean,
..ArchiveOptions::default()
};
let result = run(ArchiveRequest::Single(session_path), options);
unsafe {
match prev {
Some(v) => std::env::set_var("MX_CODEX_PATH", v),
None => std::env::remove_var("MX_CODEX_PATH"),
}
}
let result = result.expect("archive::run failed");
assert_eq!(result.archived_count, 1);
let archive_dir = result.archive_paths.first().expect("no archive dir");
let manifest_text =
std::fs::read_to_string(archive_dir.join("manifest.json")).expect("manifest missing");
let m: Manifest = serde_json::from_str(&manifest_text).unwrap();
(manifest_text, m)
}
#[test]
#[serial]
fn manifest_golden_single_full() {
let (manifest_text, m) = archive_and_read_manifest(false);
assert!(m.tool_output_count.is_none());
assert!(m.mcp_log_count.is_none());
assert!(m.history_lines.is_none());
assert!(m.source_breakdown.is_none());
assert_manifest_matches_golden(&manifest_text, &golden_path("single-full.json"));
}
#[test]
#[serial]
fn manifest_golden_single_clean() {
let (manifest_text, m) = archive_and_read_manifest(true);
assert_eq!(m.has_clean_transcript, Some(true));
assert!(m.tool_output_count.is_none());
assert!(m.source_breakdown.is_none());
assert_manifest_matches_golden(&manifest_text, &golden_path("single-clean.json"));
}
}