use crate::codex::Manifest;
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
const INDEX_SUBDIR: &str = "by-project";
const STAGING_SUBDIR: &str = "by-project.staging";
const OLD_SUBDIR: &str = "by-project.old";
#[derive(Debug, Default)]
pub struct ProjectIndex {
root: PathBuf,
entries: Vec<ProjectEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectEntry {
pub basename_slug: String,
pub absolute_paths: Vec<PathBuf>,
pub session_archive_paths: Vec<PathBuf>,
}
impl ProjectEntry {
pub fn first_absolute_path(&self) -> Option<&PathBuf> {
self.absolute_paths.first()
}
}
impl ProjectIndex {
pub fn open() -> Result<Self> {
Self::open_under(&crate::paths::codex_dir())
}
pub fn open_under(codex_dir: &Path) -> Result<Self> {
let root = codex_dir.join(INDEX_SUBDIR);
fs::create_dir_all(&root)?;
let mut idx = Self {
root,
entries: Vec::new(),
};
match idx.is_stale() {
Ok(false) => {
if let Err(e) = idx.populate_from_disk() {
eprintln!(
"warning: by-project index cache population failed; falling back \
to manifest walk: {e}"
);
}
}
Ok(true) => {
}
Err(e) => {
eprintln!("warning: by-project staleness check failed: {e}");
}
}
Ok(idx)
}
fn populate_from_disk(&mut self) -> Result<()> {
if !self.root.exists() {
return Ok(());
}
let codex_dir = self.root.parent().ok_or_else(|| {
anyhow::anyhow!("by-project root has no parent: {}", self.root.display())
})?;
let mut by_slug: HashMap<String, (Vec<PathBuf>, Vec<PathBuf>)> = HashMap::new();
for slug_entry in fs::read_dir(&self.root)? {
let slug_entry = slug_entry?;
let bucket = slug_entry.path();
if !bucket.is_dir() {
continue;
}
let slug = match bucket.file_name().and_then(|n| n.to_str()) {
Some(s) => s.to_string(),
None => continue,
};
let cell = by_slug.entry(slug).or_default();
let entries = match fs::read_dir(&bucket) {
Ok(rd) => rd,
Err(_) => continue,
};
for archive_entry in entries.flatten() {
let archive_name = match archive_entry.file_name().to_str().map(String::from) {
Some(n) => n,
None => continue,
};
let resolved = codex_dir.join(&archive_name);
let manifest_path = resolved.join("manifest.json");
if !manifest_path.exists() {
continue;
}
let raw = match fs::read_to_string(&manifest_path) {
Ok(r) => r,
Err(e) => {
eprintln!(
"warning: skipping unreadable manifest {}: {e}",
manifest_path.display()
);
continue;
}
};
let manifest: Manifest = match serde_json::from_str(&raw) {
Ok(m) => m,
Err(e) => {
eprintln!(
"warning: skipping unparseable manifest {}: {e}",
manifest_path.display()
);
continue;
}
};
if let Some(p) = manifest.project_path.as_ref() {
cell.0.push(PathBuf::from(p));
}
cell.1.push(resolved);
}
}
let mut entries: Vec<ProjectEntry> = by_slug
.into_iter()
.map(|(slug, (mut abs, mut paths))| {
abs.sort();
abs.dedup();
paths.sort();
ProjectEntry {
basename_slug: slug,
absolute_paths: abs,
session_archive_paths: paths,
}
})
.filter(|e| !e.absolute_paths.is_empty())
.collect();
entries.sort_by(|a, b| a.basename_slug.cmp(&b.basename_slug));
self.entries = entries;
Ok(())
}
pub fn rebuild_from_manifests(&mut self) -> Result<()> {
let codex_dir = self
.root
.parent()
.ok_or_else(|| {
anyhow::anyhow!(
"by-project index root has no parent: {}",
self.root.display()
)
})?
.to_path_buf();
let mut by_basename: HashMap<String, Vec<(PathBuf, PathBuf)>> = HashMap::new();
let mut session_count = 0usize;
if codex_dir.exists() {
for entry in fs::read_dir(&codex_dir)? {
let entry = entry?;
let archive_dir = entry.path();
if !archive_dir.is_dir() {
continue;
}
let name = match archive_dir.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => continue,
};
if name == INDEX_SUBDIR || name == STAGING_SUBDIR || name == OLD_SUBDIR {
continue;
}
let manifest_path = archive_dir.join("manifest.json");
if !manifest_path.exists() {
continue;
}
let raw = match fs::read_to_string(&manifest_path) {
Ok(r) => r,
Err(e) => {
eprintln!(
"warning: skipping unreadable manifest {}: {e}",
manifest_path.display()
);
continue;
}
};
let manifest: Manifest = match serde_json::from_str(&raw) {
Ok(m) => m,
Err(e) => {
eprintln!(
"warning: skipping unparseable manifest {}: {e}",
manifest_path.display()
);
continue;
}
};
let abs = match manifest.project_path.as_ref() {
Some(p) => PathBuf::from(p),
None => continue, };
let slug = basename_slug_for(&abs);
by_basename
.entry(slug)
.or_default()
.push((abs, archive_dir.clone()));
session_count += 1;
}
}
let staging = codex_dir.join(STAGING_SUBDIR);
if staging.exists() {
fs::remove_dir_all(&staging)?;
}
fs::create_dir_all(&staging)?;
for (slug, archives) in &by_basename {
let bucket = staging.join(slug);
fs::create_dir_all(&bucket)?;
for (_abs, archive_dir) in archives {
let archive_name = match archive_dir.file_name() {
Some(n) => n,
None => continue,
};
let target = PathBuf::from("..").join("..").join(archive_name);
let link = bucket.join(archive_name);
if let Err(e) = make_symlink(&target, &link) {
eprintln!("warning: failed to create symlink {}: {e}", link.display());
}
}
}
let old_dir = codex_dir.join(OLD_SUBDIR);
if old_dir.exists() {
fs::remove_dir_all(&old_dir)?;
}
let had_existing = self.root.exists();
if had_existing {
fs::rename(&self.root, &old_dir).with_context(|| {
format!(
"by-project index swap: could not rename {} aside to {}",
self.root.display(),
old_dir.display()
)
})?;
}
match fs::rename(&staging, &self.root) {
Ok(()) => {
if had_existing && let Err(e) = fs::remove_dir_all(&old_dir) {
eprintln!(
"warning: failed to clean up {} after index swap: {e}",
old_dir.display()
);
}
}
Err(swap_err) => {
if had_existing && let Err(restore_err) = fs::rename(&old_dir, &self.root) {
eprintln!(
"warning: failed to restore previous index after a failed swap: \
original swap error was {swap_err}; restore error: {restore_err}"
);
}
return Err(swap_err).with_context(|| {
format!(
"by-project index swap: rename {} -> {} failed",
staging.display(),
self.root.display()
)
});
}
}
self.entries = by_basename
.into_iter()
.map(|(slug, archives)| {
let mut session_archive_paths: Vec<PathBuf> =
archives.iter().map(|(_, p)| p.clone()).collect();
session_archive_paths.sort();
let mut absolute_paths: Vec<PathBuf> =
archives.iter().map(|(a, _)| a.clone()).collect();
absolute_paths.sort();
absolute_paths.dedup();
ProjectEntry {
basename_slug: slug,
absolute_paths,
session_archive_paths,
}
})
.collect();
self.entries
.sort_by(|a, b| a.basename_slug.cmp(&b.basename_slug));
if std::env::var("MX_VERBOSE").is_ok() {
eprintln!(
"Rebuilt by-project index: {} project(s), {} session(s)",
self.entries.len(),
session_count
);
}
Ok(())
}
pub fn lookup(&self, query: &str) -> Result<ProjectEntry> {
if query.is_empty() {
return Err(IndexError::NotFound {
query: query.to_string(),
}
.into());
}
if query.starts_with('/') {
let needle = PathBuf::from(query);
if !self.entries.is_empty() {
for entry in &self.entries {
if entry.absolute_paths.iter().any(|p| p == &needle) {
return Ok(entry.clone());
}
}
return Err(IndexError::NotFound {
query: query.to_string(),
}
.into());
}
return self.lookup_via_manifests(|m_abs| m_abs == needle, query);
}
if query.starts_with('-') {
let basename = query.rsplit('-').next().unwrap_or(query);
return self.lookup_by_basename(basename);
}
self.lookup_by_basename(query)
}
fn lookup_by_basename(&self, basename: &str) -> Result<ProjectEntry> {
if !self.entries.is_empty() {
let matches: Vec<&ProjectEntry> = self
.entries
.iter()
.filter(|e| e.basename_slug == basename)
.collect();
return match matches.len() {
0 => Err(IndexError::NotFound {
query: basename.to_string(),
}
.into()),
1 => {
let entry = matches[0].clone();
if entry.absolute_paths.len() > 1 {
Err(IndexError::AmbiguousProject {
query: basename.to_string(),
matches: entry.absolute_paths.clone(),
}
.into())
} else {
Ok(entry)
}
}
_ => {
let merged: Vec<PathBuf> = matches
.iter()
.flat_map(|e| e.absolute_paths.iter().cloned())
.collect();
Err(IndexError::AmbiguousProject {
query: basename.to_string(),
matches: merged,
}
.into())
}
};
}
let bucket = self.root.join(basename);
if !bucket.exists() {
return self.lookup_via_manifests(
|m_abs| {
m_abs
.file_name()
.and_then(|s| s.to_str())
.map(|s| s == basename)
.unwrap_or(false)
},
basename,
);
}
let codex_dir = self.root.parent().ok_or_else(|| {
anyhow::anyhow!("by-project root has no parent: {}", self.root.display())
})?;
let mut session_archive_paths: Vec<PathBuf> = Vec::new();
let mut absolute_paths: Vec<PathBuf> = Vec::new();
for entry in fs::read_dir(&bucket)? {
let entry = entry?;
let archive_name = entry.file_name();
let resolved = codex_dir.join(&archive_name);
session_archive_paths.push(resolved.clone());
let manifest_path = resolved.join("manifest.json");
match fs::read_to_string(&manifest_path) {
Ok(raw) => match serde_json::from_str::<Manifest>(&raw) {
Ok(m) => {
if let Some(p) = m.project_path {
absolute_paths.push(PathBuf::from(p));
}
}
Err(e) => eprintln!(
"warning: skipping unparseable manifest {}: {e}",
manifest_path.display()
),
},
Err(e) => {
if e.kind() != std::io::ErrorKind::NotFound {
eprintln!(
"warning: skipping unreadable manifest {}: {e}",
manifest_path.display()
);
}
}
}
}
absolute_paths.sort();
absolute_paths.dedup();
session_archive_paths.sort();
if absolute_paths.is_empty() {
return Err(IndexError::NotFound {
query: basename.to_string(),
}
.into());
}
if absolute_paths.len() > 1 {
return Err(IndexError::AmbiguousProject {
query: basename.to_string(),
matches: absolute_paths,
}
.into());
}
Ok(ProjectEntry {
basename_slug: basename.to_string(),
absolute_paths,
session_archive_paths,
})
}
fn lookup_via_manifests(
&self,
matches: impl Fn(&Path) -> bool,
query: &str,
) -> Result<ProjectEntry> {
let codex_dir = self.root.parent().ok_or_else(|| {
anyhow::anyhow!("by-project root has no parent: {}", self.root.display())
})?;
if !codex_dir.exists() {
return Err(IndexError::NotFound {
query: query.to_string(),
}
.into());
}
let mut absolute_paths: Vec<PathBuf> = Vec::new();
let mut session_archive_paths: Vec<PathBuf> = Vec::new();
let mut basename: Option<String> = None;
for entry in fs::read_dir(codex_dir)? {
let entry = entry?;
let archive_dir = entry.path();
if !archive_dir.is_dir() {
continue;
}
let name = match archive_dir.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => continue,
};
if name == INDEX_SUBDIR || name == STAGING_SUBDIR || name == OLD_SUBDIR {
continue;
}
let manifest_path = archive_dir.join("manifest.json");
if !manifest_path.exists() {
continue;
}
let raw = match fs::read_to_string(&manifest_path) {
Ok(r) => r,
Err(e) => {
eprintln!(
"warning: skipping unreadable manifest {}: {e}",
manifest_path.display()
);
continue;
}
};
let manifest: Manifest = match serde_json::from_str(&raw) {
Ok(m) => m,
Err(e) => {
eprintln!(
"warning: skipping unparseable manifest {}: {e}",
manifest_path.display()
);
continue;
}
};
let abs = match manifest.project_path.as_ref() {
Some(p) => PathBuf::from(p),
None => continue,
};
if !matches(&abs) {
continue;
}
if basename.is_none() {
basename = Some(basename_slug_for(&abs));
}
session_archive_paths.push(archive_dir);
absolute_paths.push(abs);
}
absolute_paths.sort();
absolute_paths.dedup();
session_archive_paths.sort();
if absolute_paths.is_empty() {
return Err(IndexError::NotFound {
query: query.to_string(),
}
.into());
}
if absolute_paths.len() > 1 {
return Err(IndexError::AmbiguousProject {
query: query.to_string(),
matches: absolute_paths,
}
.into());
}
Ok(ProjectEntry {
basename_slug: basename.unwrap_or_else(|| query.to_string()),
absolute_paths,
session_archive_paths,
})
}
pub fn is_stale(&self) -> Result<bool> {
if !self.root.exists() {
return Ok(true);
}
let codex_dir = self.root.parent().ok_or_else(|| {
anyhow::anyhow!("by-project root has no parent: {}", self.root.display())
})?;
if !codex_dir.exists() {
return Ok(false);
}
let index_mtime = fs::metadata(&self.root)?.modified()?;
let mut newest_manifest: Option<std::time::SystemTime> = None;
for entry in fs::read_dir(codex_dir)? {
let entry = entry?;
let archive_dir = entry.path();
if !archive_dir.is_dir() {
continue;
}
let name = match archive_dir.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => continue,
};
if name == INDEX_SUBDIR || name == STAGING_SUBDIR || name == OLD_SUBDIR {
continue;
}
let manifest_path = archive_dir.join("manifest.json");
if !manifest_path.exists() {
continue;
}
let mtime = match fs::metadata(&manifest_path).and_then(|m| m.modified()) {
Ok(t) => t,
Err(_) => continue,
};
newest_manifest = Some(match newest_manifest {
Some(prev) if prev >= mtime => prev,
_ => mtime,
});
}
match newest_manifest {
None => Ok(false),
Some(t) => Ok(t > index_mtime),
}
}
pub fn entry_count(&self) -> usize {
self.entries.len()
}
#[cfg(test)]
pub(crate) fn root(&self) -> &Path {
&self.root
}
}
fn basename_slug_for(absolute_path: &Path) -> String {
absolute_path
.file_name()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
.unwrap_or_else(|| "home".to_string())
}
fn make_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
#[cfg(unix)]
{
std::os::unix::fs::symlink(target, link)
}
#[cfg(not(unix))]
{
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"by-project index requires Unix symlinks",
))
}
}
#[derive(Debug)]
pub enum IndexError {
AmbiguousProject {
query: String,
matches: Vec<PathBuf>,
},
NotFound {
query: String,
},
}
impl std::fmt::Display for IndexError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
IndexError::AmbiguousProject { query, matches } => {
writeln!(
f,
"project '{}' is ambiguous — matches multiple absolute paths:",
query
)?;
for p in matches {
writeln!(f, " {}", p.display())?;
}
write!(f, "Disambiguate by passing the absolute path.")
}
IndexError::NotFound { query } => {
write!(
f,
"project query '{}' did not match any archived session",
query
)
}
}
}
}
impl std::error::Error for IndexError {}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
fn write_manifest(archive_dir: &Path, project_path: &str, session_id: &str) {
fs::create_dir_all(archive_dir).unwrap();
let manifest = Manifest {
version: crate::codex::MANIFEST_WRITE_VERSION,
session_id: session_id.to_string(),
archived_at: Utc::now(),
session_start: Utc::now(),
session_end: Utc::now(),
project_path: Some(project_path.to_string()),
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: None,
mcp_log_count: None,
history_lines: None,
source_breakdown: None,
};
fs::write(
archive_dir.join("manifest.json"),
serde_json::to_string_pretty(&manifest).unwrap(),
)
.unwrap();
}
#[test]
fn project_index_default_is_empty() {
let idx = ProjectIndex::default();
assert_eq!(idx.entry_count(), 0);
}
#[test]
fn project_entry_constructable() {
let entry = ProjectEntry {
basename_slug: "mx".to_string(),
absolute_paths: vec![PathBuf::from("/home/charlie/recipes/coryzibell/mx")],
session_archive_paths: vec![PathBuf::from(
"/home/charlie/.wonka/codex/2026-04-29-143022-c3744b8d",
)],
};
assert_eq!(entry.basename_slug, "mx");
assert_eq!(entry.session_archive_paths.len(), 1);
assert_eq!(entry.absolute_paths.len(), 1);
assert_eq!(
entry.first_absolute_path(),
Some(&PathBuf::from("/home/charlie/recipes/coryzibell/mx"))
);
}
#[test]
fn rebuild_collects_all_absolute_paths_for_ambiguous_basename() {
let tmp = tempfile::tempdir().unwrap();
let codex = tmp.path();
write_manifest(
&codex.join("2026-04-29-100000-aaaaaaaa"),
"/home/alice/recipes/mx",
"aaa",
);
write_manifest(
&codex.join("2026-04-29-110000-bbbbbbbb"),
"/home/bob/work/mx",
"bbb",
);
let mut idx = ProjectIndex::open_under(codex).unwrap();
idx.rebuild_from_manifests().unwrap();
assert_eq!(idx.entry_count(), 1, "single basename, two abs paths");
let entry = &idx.entries[0];
assert_eq!(entry.basename_slug, "mx");
assert_eq!(entry.absolute_paths.len(), 2);
assert!(
entry
.absolute_paths
.contains(&PathBuf::from("/home/alice/recipes/mx"))
);
assert!(
entry
.absolute_paths
.contains(&PathBuf::from("/home/bob/work/mx"))
);
}
#[test]
fn index_error_ambiguous_renders_query_and_matches() {
let err = IndexError::AmbiguousProject {
query: "mx".to_string(),
matches: vec![PathBuf::from("/home/a/mx"), PathBuf::from("/home/b/mx")],
};
let msg = format!("{}", err);
assert!(msg.contains("'mx'"));
assert!(msg.contains("/home/a/mx"));
assert!(msg.contains("/home/b/mx"));
assert!(
!msg.contains("\"/home/a/mx\""),
"must not use debug-quoted paths"
);
assert!(
!msg.contains("PathBuf"),
"must not leak debug type names: {msg}"
);
assert!(msg.contains("\n /home/a/mx"));
assert!(msg.contains("\n /home/b/mx"));
assert!(msg.contains("Disambiguate by passing the absolute path."));
}
#[test]
fn open_creates_by_project_dir() {
let tmp = tempfile::tempdir().unwrap();
let idx = ProjectIndex::open_under(tmp.path()).unwrap();
assert!(idx.root().exists());
assert!(idx.root().ends_with(INDEX_SUBDIR));
}
#[test]
fn open_is_idempotent() {
let tmp = tempfile::tempdir().unwrap();
let _idx1 = ProjectIndex::open_under(tmp.path()).unwrap();
let _idx2 = ProjectIndex::open_under(tmp.path()).unwrap();
}
#[test]
fn rebuild_from_empty_codex() {
let tmp = tempfile::tempdir().unwrap();
let mut idx = ProjectIndex::open_under(tmp.path()).unwrap();
idx.rebuild_from_manifests().unwrap();
assert_eq!(idx.entry_count(), 0);
assert!(idx.root().exists());
}
#[test]
#[cfg(unix)]
fn rebuild_populated_codex_creates_symlinks() {
let tmp = tempfile::tempdir().unwrap();
let codex = tmp.path();
write_manifest(
&codex.join("2026-04-29-100000-aaaaaaaa"),
"/home/charlie/recipes/coryzibell/mx",
"aaa",
);
write_manifest(
&codex.join("2026-04-29-110000-bbbbbbbb"),
"/home/charlie/recipes/coryzibell/mx",
"bbb",
);
write_manifest(
&codex.join("2026-04-29-120000-cccccccc"),
"/home/charlie/recipes/coryzibell/wonka",
"ccc",
);
let mut idx = ProjectIndex::open_under(codex).unwrap();
idx.rebuild_from_manifests().unwrap();
assert_eq!(idx.entry_count(), 2, "two distinct projects expected");
let mx_dir = codex.join(INDEX_SUBDIR).join("mx");
let wonka_dir = codex.join(INDEX_SUBDIR).join("wonka");
assert!(mx_dir.exists());
assert!(wonka_dir.exists());
assert!(mx_dir.join("2026-04-29-100000-aaaaaaaa").exists());
assert!(mx_dir.join("2026-04-29-110000-bbbbbbbb").exists());
assert!(wonka_dir.join("2026-04-29-120000-cccccccc").exists());
let link = mx_dir.join("2026-04-29-100000-aaaaaaaa");
let target = fs::read_link(&link).unwrap();
assert_eq!(target, PathBuf::from("../../2026-04-29-100000-aaaaaaaa"));
let resolved = fs::canonicalize(&link).unwrap();
let expected = fs::canonicalize(codex.join("2026-04-29-100000-aaaaaaaa")).unwrap();
assert_eq!(resolved, expected);
}
#[test]
fn rebuild_skips_archives_without_project_path() {
let tmp = tempfile::tempdir().unwrap();
let codex = tmp.path();
let archive = codex.join("2026-04-29-130000-dddddddd");
fs::create_dir_all(&archive).unwrap();
let manifest_json = r#"{
"version": 5,
"session_id": "ddd",
"archived_at": "2026-04-29T13:00:00Z",
"session_start": "2026-04-29T13:00:00Z",
"session_end": "2026-04-29T13:00:00Z",
"project_path": null,
"message_count": 0,
"agent_count": 0,
"agents": [],
"size_bytes": 0,
"checksum": "sha256:zero"
}"#;
fs::write(archive.join("manifest.json"), manifest_json).unwrap();
let mut idx = ProjectIndex::open_under(codex).unwrap();
idx.rebuild_from_manifests().unwrap();
assert_eq!(idx.entry_count(), 0);
}
#[test]
fn rebuild_happy_path_leaves_no_old_dir() {
let tmp = tempfile::tempdir().unwrap();
let codex = tmp.path();
write_manifest(
&codex.join("2026-04-29-100000-aaaaaaaa"),
"/home/test/foo",
"aaa",
);
let mut idx = ProjectIndex::open_under(codex).unwrap();
idx.rebuild_from_manifests().unwrap();
idx.rebuild_from_manifests().unwrap();
assert!(codex.join(INDEX_SUBDIR).exists());
assert!(
!codex.join(OLD_SUBDIR).exists(),
".old sidelined dir leaked after happy-path rebuild"
);
}
#[test]
#[cfg(unix)]
fn rebuild_rolls_back_on_swap_failure() {
let tmp = tempfile::tempdir().unwrap();
let codex = tmp.path();
write_manifest(
&codex.join("2026-04-29-100000-aaaaaaaa"),
"/home/test/foo",
"aaa",
);
let mut idx = ProjectIndex::open_under(codex).unwrap();
idx.rebuild_from_manifests().unwrap();
let foo_link = codex
.join(INDEX_SUBDIR)
.join("foo")
.join("2026-04-29-100000-aaaaaaaa");
assert!(
foo_link.exists(),
"first rebuild did not produce expected symlink"
);
fs::remove_dir_all(codex.join(INDEX_SUBDIR)).unwrap();
fs::write(codex.join(INDEX_SUBDIR), "not a dir").unwrap();
fs::write(codex.join(OLD_SUBDIR), "stale leftover").unwrap();
let result = idx.rebuild_from_manifests();
assert!(
result.is_err(),
"rebuild should have errored on the sabotaged stale-.old"
);
assert!(codex.join(INDEX_SUBDIR).exists());
}
#[test]
fn rebuild_replaces_existing_index_atomically() {
let tmp = tempfile::tempdir().unwrap();
let codex = tmp.path();
write_manifest(
&codex.join("2026-04-29-100000-aaaaaaaa"),
"/home/test/foo",
"aaa",
);
let mut idx = ProjectIndex::open_under(codex).unwrap();
idx.rebuild_from_manifests().unwrap();
assert!(codex.join(INDEX_SUBDIR).join("foo").exists());
write_manifest(
&codex.join("2026-04-29-110000-bbbbbbbb"),
"/home/test/bar",
"bbb",
);
idx.rebuild_from_manifests().unwrap();
assert!(codex.join(INDEX_SUBDIR).join("foo").exists());
assert!(codex.join(INDEX_SUBDIR).join("bar").exists());
assert!(!codex.join(STAGING_SUBDIR).exists());
}
#[test]
fn basename_slug_for_normal_path() {
assert_eq!(
basename_slug_for(Path::new("/home/charlie/recipes/coryzibell/mx")),
"mx"
);
}
#[test]
fn basename_slug_for_root_falls_back() {
assert_eq!(basename_slug_for(Path::new("/")), "home");
}
fn populated_index() -> (ProjectIndex, tempfile::TempDir) {
let tmp = tempfile::tempdir().unwrap();
let codex = tmp.path().to_path_buf();
write_manifest(
&codex.join("2026-04-29-100000-aaaaaaaa"),
"/home/charlie/work/mx",
"aaa",
);
let mut idx = ProjectIndex::open_under(&codex).unwrap();
idx.rebuild_from_manifests().unwrap();
(idx, tmp)
}
#[test]
fn lookup_by_basename_returns_entry() {
let (idx, _tmp) = populated_index();
let entry = idx.lookup("mx").expect("basename lookup should succeed");
assert_eq!(entry.basename_slug, "mx");
assert_eq!(entry.absolute_paths.len(), 1);
assert_eq!(
entry.absolute_paths[0],
PathBuf::from("/home/charlie/work/mx")
);
}
#[test]
fn lookup_by_absolute_path_returns_entry() {
let (idx, _tmp) = populated_index();
let entry = idx
.lookup("/home/charlie/work/mx")
.expect("abs path lookup should succeed");
assert_eq!(entry.basename_slug, "mx");
}
#[test]
fn lookup_by_raw_slug_returns_entry() {
let (idx, _tmp) = populated_index();
let entry = idx
.lookup("-home-charlie-work-mx")
.expect("raw slug lookup should succeed");
assert_eq!(entry.basename_slug, "mx");
}
#[test]
fn lookup_ambiguous_basename_returns_ambiguous_error() {
let tmp = tempfile::tempdir().unwrap();
let codex = tmp.path();
write_manifest(
&codex.join("2026-04-29-100000-aaaaaaaa"),
"/home/alice/recipes/mx",
"aaa",
);
write_manifest(
&codex.join("2026-04-29-110000-bbbbbbbb"),
"/home/bob/work/mx",
"bbb",
);
let mut idx = ProjectIndex::open_under(codex).unwrap();
idx.rebuild_from_manifests().unwrap();
let err = idx.lookup("mx").unwrap_err();
let downcast = err.downcast_ref::<IndexError>().expect("IndexError");
match downcast {
IndexError::AmbiguousProject { query, matches } => {
assert_eq!(query, "mx");
assert_eq!(matches.len(), 2);
}
other => panic!("expected AmbiguousProject, got {other:?}"),
}
}
#[test]
fn lookup_not_found_returns_notfound_error() {
let (idx, _tmp) = populated_index();
let err = idx.lookup("does-not-exist").unwrap_err();
let downcast = err.downcast_ref::<IndexError>().expect("IndexError");
assert!(matches!(downcast, IndexError::NotFound { .. }));
}
#[test]
#[cfg(unix)]
fn open_populates_cache_from_existing_index() {
let tmp = tempfile::tempdir().unwrap();
let codex = tmp.path();
write_manifest(
&codex.join("2026-04-29-100000-aaaaaaaa"),
"/home/charlie/work/mx",
"aaa",
);
write_manifest(
&codex.join("2026-04-29-110000-bbbbbbbb"),
"/home/charlie/work/wonka",
"bbb",
);
{
let mut idx = ProjectIndex::open_under(codex).unwrap();
idx.rebuild_from_manifests().unwrap();
}
let idx = ProjectIndex::open_under(codex).unwrap();
assert_eq!(idx.entry_count(), 2, "cache should be populated on open");
let entry = idx
.lookup("/home/charlie/work/mx")
.expect("abs-path lookup should hit the populated cache");
assert_eq!(entry.basename_slug, "mx");
}
#[test]
fn open_leaves_cache_empty_when_index_is_stale() {
let tmp = tempfile::tempdir().unwrap();
let codex = tmp.path();
write_manifest(
&codex.join("2026-04-29-100000-aaaaaaaa"),
"/home/charlie/work/mx",
"aaa",
);
{
let mut idx = ProjectIndex::open_under(codex).unwrap();
idx.rebuild_from_manifests().unwrap();
}
std::thread::sleep(std::time::Duration::from_millis(10));
write_manifest(
&codex.join("2026-04-29-110000-bbbbbbbb"),
"/home/charlie/work/wonka",
"bbb",
);
let idx = ProjectIndex::open_under(codex).unwrap();
assert_eq!(
idx.entry_count(),
0,
"stale index should not seed an outdated cache"
);
let entry = idx
.lookup("/home/charlie/work/wonka")
.expect("manifest-walk fallback must find the new archive");
assert_eq!(entry.basename_slug, "wonka");
}
#[test]
fn lookup_via_manifests_skips_bad_manifests_without_panic() {
let tmp = tempfile::tempdir().unwrap();
let codex = tmp.path();
write_manifest(
&codex.join("2026-04-29-100000-aaaaaaaa"),
"/home/charlie/work/mx",
"aaa",
);
let bad_archive = codex.join("2026-04-29-110000-bbbbbbbb");
fs::create_dir_all(&bad_archive).unwrap();
fs::write(bad_archive.join("manifest.json"), "{not json").unwrap();
let idx = ProjectIndex::open_under(codex).unwrap();
let entry = idx
.lookup("/home/charlie/work/mx")
.expect("good manifest should still resolve");
assert_eq!(entry.basename_slug, "mx");
}
#[test]
fn lookup_falls_back_to_manifest_walk_when_cache_empty() {
let tmp = tempfile::tempdir().unwrap();
let codex = tmp.path();
write_manifest(
&codex.join("2026-04-29-100000-aaaaaaaa"),
"/home/charlie/work/mx",
"aaa",
);
let idx = ProjectIndex::open_under(codex).unwrap();
let entry = idx
.lookup("/home/charlie/work/mx")
.expect("manifest-walk fallback should resolve abs path");
assert_eq!(entry.basename_slug, "mx");
}
#[test]
fn is_stale_fresh_codex_no_archives_is_not_stale() {
let tmp = tempfile::tempdir().unwrap();
let idx = ProjectIndex::open_under(tmp.path()).unwrap();
assert!(!idx.is_stale().unwrap());
}
#[test]
fn is_stale_after_archive_added_is_stale() {
let tmp = tempfile::tempdir().unwrap();
let codex = tmp.path();
let mut idx = ProjectIndex::open_under(codex).unwrap();
idx.rebuild_from_manifests().unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
write_manifest(
&codex.join("2026-04-29-100000-aaaaaaaa"),
"/home/charlie/work/mx",
"aaa",
);
assert!(
idx.is_stale().unwrap(),
"manifest written after rebuild should mark index stale"
);
}
#[test]
fn is_stale_after_rebuild_is_not_stale() {
let tmp = tempfile::tempdir().unwrap();
let codex = tmp.path();
write_manifest(
&codex.join("2026-04-29-100000-aaaaaaaa"),
"/home/charlie/work/mx",
"aaa",
);
let mut idx = ProjectIndex::open_under(codex).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
idx.rebuild_from_manifests().unwrap();
assert!(
!idx.is_stale().unwrap(),
"fresh rebuild should not be stale"
);
}
#[test]
fn is_stale_when_index_dir_missing_is_stale() {
let tmp = tempfile::tempdir().unwrap();
let codex = tmp.path();
let idx = ProjectIndex {
root: codex.join(INDEX_SUBDIR),
entries: Vec::new(),
};
assert!(idx.is_stale().unwrap());
}
}