use anyhow::{Context, Result, anyhow};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use super::sources::{self, ArtifactSource};
use crate::artifact::{ArtifactRef, ArtifactType};
use crate::cache::write_cached;
use crate::config::{Config, MANIFEST_FILE_NAME, MANIFEST_LOCK_FILE_NAME};
use crate::harness::HarnessBundle;
const MANIFEST_CHECKPOINT_EVERY_WRITES: usize = 10;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub(crate) struct SyncRecord {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) cache_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) modified: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) size: Option<u64>,
pub(crate) synced_at: DateTime<Utc>,
}
pub(crate) type Manifest = BTreeMap<String, BTreeMap<String, SyncRecord>>;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct SyncOutcome {
pub(crate) new: usize,
pub(crate) updated: usize,
pub(crate) unchanged: usize,
pub(crate) failed: usize,
pub(crate) out_of_scope: usize,
}
impl SyncOutcome {
pub(crate) fn total(&self) -> usize {
self.new + self.updated + self.unchanged + self.failed + self.out_of_scope
}
}
pub(crate) trait SyncObserver {
fn begin(&mut self, _artifact_type: ArtifactType, _pending: usize) {}
fn tick(&mut self) {}
fn failed(&mut self, _artifact: &ArtifactRef, _error: &anyhow::Error) {}
fn end(&mut self) {}
}
impl SyncObserver for () {}
pub(crate) fn sync_bundle(
config_dir: &Path,
bundle: &HarnessBundle,
types: &[ArtifactType],
project_under: Option<&Path>,
observer: &mut dyn SyncObserver,
) -> Result<Vec<(ArtifactType, SyncOutcome)>> {
let manifest = load_manifest(config_dir)?;
let mut out = Vec::with_capacity(types.len());
for &artifact_type in types {
let Some(source) = sources::source_for(bundle, artifact_type) else {
out.push((artifact_type, SyncOutcome::default()));
continue;
};
let artifacts = source.enumerate(project_under);
let records = manifest
.get(artifact_type.name())
.cloned()
.unwrap_or_default();
let outcome = sync_artifacts(
config_dir,
source.as_ref(),
artifact_type,
&artifacts,
&records,
project_under,
observer,
)?;
out.push((artifact_type, outcome));
}
Ok(out)
}
fn is_unchanged(rec: Option<&SyncRecord>, artifact: &ArtifactRef) -> bool {
rec.is_some_and(|rec| {
(rec.modified.is_some() || rec.size.is_some())
&& rec.modified == artifact.modified
&& rec.size == artifact.size
&& rec
.cache_id
.as_deref()
.is_some_and(|id| crate::cache::cache_path(id).is_ok_and(|p| p.exists()))
})
}
fn newest_first(artifacts: &[ArtifactRef]) -> Vec<&ArtifactRef> {
let mut order: Vec<&ArtifactRef> = artifacts.iter().collect();
order.sort_by_key(|a| std::cmp::Reverse(a.modified));
order
}
fn flush_writes(
config_dir: &Path,
pending: &mut BTreeMap<&'static str, BTreeMap<String, SyncRecord>>,
) -> Result<()> {
if pending.is_empty() {
return Ok(());
}
let batch = std::mem::take(pending);
update_manifest(config_dir, move |manifest| {
for (name, records) in batch {
manifest
.entry(name.to_string())
.or_default()
.extend(records);
}
})
}
fn sync_artifacts(
config_dir: &Path,
source: &dyn ArtifactSource,
artifact_type: ArtifactType,
artifacts: &[ArtifactRef],
records: &BTreeMap<String, SyncRecord>,
project_under: Option<&Path>,
observer: &mut dyn SyncObserver,
) -> Result<SyncOutcome> {
let mut outcome = SyncOutcome::default();
let order: Vec<(&ArtifactRef, bool)> = newest_first(artifacts)
.into_iter()
.map(|artifact| (artifact, is_unchanged(records.get(&artifact.id), artifact)))
.collect();
let pending_total = order.iter().filter(|(_, unchanged)| !unchanged).count();
observer.begin(artifact_type, pending_total);
let mut writes: BTreeMap<&'static str, BTreeMap<String, SyncRecord>> = BTreeMap::new();
let mut unflushed = 0usize;
for (artifact, unchanged) in order {
if unchanged {
outcome.unchanged += 1;
continue;
}
let existing = records.get(&artifact.id);
let is_new = existing.is_none();
let stage = |writes: &mut BTreeMap<&'static str, BTreeMap<String, SyncRecord>>,
record: SyncRecord| {
writes
.entry(artifact.artifact_type.name())
.or_default()
.insert(artifact.id.clone(), record);
};
if let Some(project_under) = project_under {
let dir = artifact
.path
.clone()
.or_else(|| existing.and_then(|r| r.path.clone()))
.or_else(|| source.peek_dir(&artifact.id));
let in_scope = dir
.as_deref()
.is_some_and(|d| source.in_scope(d, project_under));
if !in_scope {
outcome.out_of_scope += 1;
if existing.is_none_or(|r| r.cache_id.is_none()) {
stage(
&mut writes,
SyncRecord {
path: dir,
cache_id: None,
modified: artifact.modified,
size: artifact.size,
synced_at: Utc::now(),
},
);
unflushed += 1;
}
observer.tick();
if unflushed >= MANIFEST_CHECKPOINT_EVERY_WRITES {
flush_writes(config_dir, &mut writes)?;
unflushed = 0;
}
continue;
}
}
let memoized_path = artifact
.path
.clone()
.or_else(|| existing.and_then(|r| r.path.clone()));
match source.derive(artifact) {
Ok(derived) => {
write_cached(&derived.cache_id, &derived.doc, true)?;
stage(
&mut writes,
SyncRecord {
path: memoized_path,
cache_id: Some(derived.cache_id),
modified: artifact.modified,
size: artifact.size,
synced_at: Utc::now(),
},
);
unflushed += 1;
if is_new {
outcome.new += 1;
} else {
outcome.updated += 1;
}
}
Err(e) => {
observer.failed(artifact, &e);
outcome.failed += 1;
}
}
observer.tick();
if unflushed >= MANIFEST_CHECKPOINT_EVERY_WRITES {
flush_writes(config_dir, &mut writes)?;
unflushed = 0;
}
}
flush_writes(config_dir, &mut writes)?;
observer.end();
Ok(outcome)
}
pub(crate) fn record_artifact(
config: &Config,
artifact: &ArtifactRef,
cache_id: &str,
) -> Result<()> {
let config_dir = config.config_dir()?;
update_manifest(&config_dir, |manifest| {
manifest
.entry(artifact.artifact_type.name().to_string())
.or_default()
.insert(
artifact.id.clone(),
SyncRecord {
path: artifact.path.clone(),
cache_id: Some(cache_id.to_string()),
modified: artifact.modified,
size: artifact.size,
synced_at: Utc::now(),
},
);
})
}
pub(crate) fn record_is_current(config: &Config, artifact: &ArtifactRef, cache_id: &str) -> bool {
let Ok(config_dir) = config.config_dir() else {
return false;
};
let Ok(manifest) = load_manifest(&config_dir) else {
return false;
};
manifest
.get(artifact.artifact_type.name())
.and_then(|records| records.get(&artifact.id))
.is_some_and(|rec| {
rec.cache_id.as_deref() == Some(cache_id)
&& (rec.modified.is_some() || rec.size.is_some())
&& rec.modified == artifact.modified
&& rec.size == artifact.size
&& crate::cache::cache_path(cache_id).is_ok_and(|p| p.exists())
})
}
pub(crate) fn fresh_cache_id(
bundle: &HarnessBundle,
artifact_type: ArtifactType,
project: Option<&str>,
id: &str,
) -> Option<String> {
let config_dir = Config::load().ok()?.config_dir().ok()?;
let manifest = load_manifest(&config_dir).ok()?;
let rec = manifest.get(artifact_type.name())?.get(id)?;
let cache_id = rec.cache_id.clone()?;
let (modified, size) = sources::source_for(bundle, artifact_type)?.stamp(project, id)?;
((rec.modified.is_some() || rec.size.is_some())
&& rec.modified == modified
&& rec.size == size
&& crate::cache::cache_path(&cache_id).is_ok_and(|p| p.exists()))
.then_some(cache_id)
}
pub(crate) fn evict_cache_id(config_dir: &Path, cache_id: &str) -> Result<()> {
update_manifest(config_dir, |manifest| {
for records in manifest.values_mut() {
for rec in records.values_mut() {
if rec.cache_id.as_deref() == Some(cache_id) {
rec.cache_id = None;
}
}
}
})
}
fn manifest_path(config_dir: &Path) -> PathBuf {
config_dir.join(MANIFEST_FILE_NAME)
}
fn lock_manifest(config_dir: &Path) -> Result<std::fs::File> {
let path = manifest_path(config_dir);
let dir = path.parent().expect("manifest path has a parent");
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
let lock_path = dir.join(MANIFEST_LOCK_FILE_NAME);
let file = std::fs::File::create(&lock_path)
.with_context(|| format!("create {}", lock_path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&lock_path, std::fs::Permissions::from_mode(0o600));
}
file.lock()
.with_context(|| format!("lock {}", lock_path.display()))?;
Ok(file)
}
fn update_manifest(config_dir: &Path, mutate: impl FnOnce(&mut Manifest)) -> Result<()> {
let _lock = lock_manifest(config_dir)?;
let mut manifest = load_manifest(config_dir)?;
mutate(&mut manifest);
save_manifest(config_dir, &manifest)
}
pub(crate) fn load_manifest(config_dir: &Path) -> Result<Manifest> {
let path = manifest_path(config_dir);
let json = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Manifest::default()),
Err(e) => return Err(anyhow!("read {}: {e}", path.display())),
};
serde_json::from_str(&json).with_context(|| {
format!(
"parse {}; delete it to re-sync from scratch",
path.display()
)
})
}
fn save_manifest(config_dir: &Path, manifest: &Manifest) -> Result<()> {
let path = manifest_path(config_dir);
let dir = path.parent().expect("manifest path has a parent");
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
}
let json = serde_json::to_string_pretty(manifest)?;
let tmp = dir.join(format!("{MANIFEST_FILE_NAME}.{}.tmp", std::process::id()));
std::fs::write(&tmp, json).with_context(|| format!("write {}", tmp.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("chmod 0600 {}", tmp.display()))?;
}
std::fs::rename(&tmp, &path)
.with_context(|| format!("rename {} → {}", tmp.display(), path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{CONFIG_DIR_ENV, TEST_ENV_LOCK};
use std::path::Path;
fn with_cfg<F: FnOnce(&Path, &Path) -> R, R>(f: F) -> R {
let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let temp = tempfile::tempdir().unwrap();
let config_root = temp.path().join(".toolpath");
let prev = std::env::var_os(CONFIG_DIR_ENV);
unsafe {
std::env::set_var(CONFIG_DIR_ENV, &config_root);
}
let result = f(temp.path(), &config_root);
unsafe {
match prev {
Some(v) => std::env::set_var(CONFIG_DIR_ENV, v),
None => std::env::remove_var(CONFIG_DIR_ENV),
}
}
result
}
fn write_claude_session(home: &Path, project_slug: &str, session: &str, prompt: &str) {
let project_dir = home.join(".claude/projects").join(project_slug);
std::fs::create_dir_all(&project_dir).unwrap();
let user = format!(
r#"{{"type":"user","uuid":"u-{session}","timestamp":"2024-01-02T00:00:00Z","cwd":"/test/project","message":{{"role":"user","content":"{prompt}"}}}}"#
);
let asst = format!(
r#"{{"type":"assistant","uuid":"a-{session}","timestamp":"2024-01-02T00:00:01Z","message":{{"role":"assistant","content":"hi"}}}}"#
);
std::fs::write(
project_dir.join(format!("{session}.jsonl")),
format!("{user}\n{asst}\n"),
)
.unwrap();
}
fn claude_bundle(home: &Path) -> HarnessBundle {
let resolver = toolpath_claude::PathResolver::new().with_claude_dir(home.join(".claude"));
HarnessBundle {
claude: Some(toolpath_claude::ClaudeConvo::with_resolver(resolver)),
..Default::default()
}
}
fn cached_step_count(cache_id: &str) -> usize {
let path = crate::cache::cache_path(cache_id).unwrap();
let json = std::fs::read_to_string(path).unwrap();
let doc = toolpath::v1::Graph::from_json(&json).unwrap();
doc.single_path().map(|p| p.steps.len()).unwrap_or(0)
}
fn make_ref(artifact_type: ArtifactType, id: &str) -> ArtifactRef {
ArtifactRef {
artifact_type,
id: id.to_string(),
path: Some("/test/project".to_string()),
modified: None,
size: None,
}
}
#[test]
fn manifest_roundtrips_and_missing_is_empty() {
with_cfg(|_, config_dir| {
assert!(load_manifest(config_dir).unwrap().is_empty());
let mut manifest = Manifest::default();
manifest.entry("claude".to_string()).or_default().insert(
"sess-1".to_string(),
SyncRecord {
path: Some("/test/project".to_string()),
cache_id: Some("claude-p1".to_string()),
modified: Some("2024-01-02T00:00:01.123456789Z".parse().unwrap()),
size: Some(4096),
synced_at: "2026-07-09T00:00:00Z".parse().unwrap(),
},
);
save_manifest(config_dir, &manifest).unwrap();
assert_eq!(load_manifest(config_dir).unwrap(), manifest);
});
}
#[cfg(unix)]
#[test]
fn manifest_file_is_0600() {
use std::os::unix::fs::PermissionsExt;
with_cfg(|_, config_dir| {
save_manifest(config_dir, &Manifest::default()).unwrap();
let mode = std::fs::metadata(manifest_path(config_dir))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600);
});
}
#[test]
fn corrupt_manifest_errors_with_hint() {
with_cfg(|_, config_dir| {
save_manifest(config_dir, &Manifest::default()).unwrap();
std::fs::write(manifest_path(config_dir), "not json").unwrap();
let err = load_manifest(config_dir).unwrap_err();
assert!(err.to_string().contains("re-sync from scratch"));
});
}
#[test]
fn enumerated_claude_sessions_are_stamped() {
with_cfg(|home, _| {
write_claude_session(home, "-test-project", "sess-aaa", "Add a feature");
let bundle = claude_bundle(home);
let source = sources::source_for(&bundle, ArtifactType::Claude).unwrap();
let artifacts = source.enumerate(None);
assert_eq!(artifacts.len(), 1);
assert_eq!(artifacts[0].id, "sess-aaa");
assert_eq!(artifacts[0].path.as_deref(), Some("/test/project"));
assert!(
artifacts[0].modified.is_some(),
"file mtime must be stamped"
);
assert!(artifacts[0].size.unwrap() > 0, "file size must be stamped");
});
}
#[test]
fn first_sync_ingests_then_second_is_unchanged() {
with_cfg(|home, config_dir| {
write_claude_session(home, "-test-project", "sess-aaa", "Add a feature");
write_claude_session(home, "-test-project", "sess-bbb", "Fix a bug");
let bundle = claude_bundle(home);
let outcomes =
sync_bundle(config_dir, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap();
assert_eq!(outcomes.len(), 1);
let (_, first) = outcomes[0];
assert_eq!(
(first.new, first.updated, first.unchanged, first.failed),
(2, 0, 0, 0)
);
let manifest = load_manifest(config_dir).unwrap();
let records = manifest.get("claude").unwrap();
assert_eq!(records.len(), 2);
let rec = records.get("sess-aaa").unwrap();
assert_eq!(rec.path.as_deref(), Some("/test/project"));
assert!(rec.modified.is_some());
assert!(rec.size.is_some());
let cache_id = rec
.cache_id
.as_deref()
.expect("synced record is materialized");
assert!(
crate::cache::cache_path(cache_id).unwrap().exists(),
"cache doc must exist for {cache_id}"
);
let (_, second) =
sync_bundle(config_dir, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap()
[0];
assert_eq!(
(second.new, second.updated, second.unchanged, second.failed),
(0, 0, 2, 0)
);
});
}
#[test]
fn changed_session_is_rederived() {
with_cfg(|home, config_dir| {
write_claude_session(home, "-test-project", "sess-aaa", "Add a feature");
let bundle = claude_bundle(home);
sync_bundle(config_dir, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap();
let cache_id = load_manifest(config_dir).unwrap()["claude"]["sess-aaa"]
.cache_id
.clone()
.expect("synced record is materialized");
let steps_before = cached_step_count(&cache_id);
let file = home.join(".claude/projects/-test-project/sess-aaa.jsonl");
let mut body = std::fs::read_to_string(&file).unwrap();
body.push_str(
r#"{"type":"user","uuid":"u-2","timestamp":"2024-01-02T00:05:00Z","cwd":"/test/project","message":{"role":"user","content":"And another thing"}}"#,
);
body.push('\n');
std::fs::write(&file, body).unwrap();
let (_, outcome) =
sync_bundle(config_dir, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap()
[0];
assert_eq!(
(
outcome.new,
outcome.updated,
outcome.unchanged,
outcome.failed
),
(0, 1, 0, 0)
);
assert!(
cached_step_count(&cache_id) > steps_before,
"re-derived doc must contain the appended turn"
);
});
}
#[test]
fn sync_touches_only_requested_types() {
with_cfg(|home, config_dir| {
write_claude_session(home, "-test-project", "sess-aaa", "Add a feature");
let bundle = claude_bundle(home);
let outcomes =
sync_bundle(config_dir, &bundle, &[ArtifactType::Codex], None, &mut ()).unwrap();
assert_eq!(outcomes[0].1, SyncOutcome::default());
assert!(
load_manifest(config_dir).unwrap().is_empty(),
"codex-only sync must not ingest claude sessions"
);
});
}
#[test]
fn sync_overwrites_cache_entry_it_does_not_remember() {
with_cfg(|home, config_dir| {
write_claude_session(home, "-test-project", "sess-aaa", "Add a feature");
let bundle = claude_bundle(home);
sync_bundle(config_dir, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap();
std::fs::remove_file(manifest_path(config_dir)).unwrap();
let (_, outcome) =
sync_bundle(config_dir, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap()
[0];
assert_eq!((outcome.new, outcome.failed), (1, 0));
});
}
#[test]
fn failed_derivation_is_tallied_and_skipped() {
with_cfg(|home, config_dir| {
write_claude_session(home, "-test-project", "sess-aaa", "Add a feature");
let bundle = claude_bundle(home);
let source = sources::source_for(&bundle, ArtifactType::Claude).unwrap();
let mut artifacts = source.enumerate(None);
artifacts.push(make_ref(ArtifactType::Claude, "does-not-exist"));
let outcome = sync_artifacts(
config_dir,
source.as_ref(),
ArtifactType::Claude,
&artifacts,
&BTreeMap::new(),
None,
&mut (),
)
.unwrap();
assert_eq!((outcome.new, outcome.failed), (1, 1));
let records = &load_manifest(config_dir).unwrap()["claude"];
assert!(records.contains_key("sess-aaa"));
assert!(
!records.contains_key("does-not-exist"),
"failed artifacts must not be recorded as synced"
);
});
}
#[test]
fn rotated_session_resyncs_under_its_head_id() {
with_cfg(|home, config_dir| {
write_claude_session(home, "-test-project", "sess-aaa", "Add a feature");
let bundle = claude_bundle(home);
sync_bundle(config_dir, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap();
let cache_id = load_manifest(config_dir).unwrap()["claude"]["sess-aaa"]
.cache_id
.clone()
.unwrap();
let steps_before = cached_step_count(&cache_id);
std::fs::write(
home.join(".claude/projects/-test-project/sess-bbb.jsonl"),
concat!(
r#"{"type":"user","uuid":"u-b0","timestamp":"2024-01-02T01:00:00Z","sessionId":"sess-aaa","cwd":"/test/project","message":{"role":"user","content":"bridge"}}"#,
"\n",
r#"{"type":"user","uuid":"u-b1","timestamp":"2024-01-02T01:00:01Z","sessionId":"sess-bbb","cwd":"/test/project","message":{"role":"user","content":"after rotation"}}"#,
"\n",
),
)
.unwrap();
let (_, outcome) =
sync_bundle(config_dir, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap()
[0];
assert_eq!(
(outcome.new, outcome.updated, outcome.unchanged),
(0, 1, 0),
"the chain must re-sync under its head id, not read as unchanged"
);
let manifest = load_manifest(config_dir).unwrap();
assert!(
!manifest["claude"].contains_key("sess-bbb"),
"successor segments are not separate artifacts"
);
assert!(
cached_step_count(&cache_id) > steps_before,
"post-rotation turns must reach the cached doc"
);
let (_, again) =
sync_bundle(config_dir, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap()
[0];
assert_eq!((again.updated, again.unchanged), (0, 1));
});
}
#[test]
fn all_none_stamps_never_read_as_unchanged() {
with_cfg(|home, config_dir| {
write_claude_session(home, "-test-project", "sess-aaa", "Add a feature");
let bundle = claude_bundle(home);
sync_bundle(config_dir, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap();
let mut records = load_manifest(config_dir).unwrap()["claude"].clone();
let rec = records.get_mut("sess-aaa").unwrap();
rec.modified = None;
rec.size = None;
let artifact = make_ref(ArtifactType::Claude, "sess-aaa");
let source = sources::source_for(&bundle, ArtifactType::Claude).unwrap();
let outcome = sync_artifacts(
config_dir,
source.as_ref(),
ArtifactType::Claude,
&[artifact],
&records,
None,
&mut (),
)
.unwrap();
assert_eq!((outcome.updated, outcome.unchanged), (1, 0));
});
}
#[test]
fn recorded_import_is_unchanged_to_the_next_sync() {
with_cfg(|home, config_dir| {
write_claude_session(home, "-test-project", "sess-aaa", "Add a feature");
let bundle = claude_bundle(home);
let derived = crate::derive::derive_claude_session_with(
bundle.claude.as_ref().unwrap(),
"/test/project",
"sess-aaa",
)
.unwrap();
let artifact = derived.provenance.as_ref().unwrap();
assert_eq!(artifact.id, "sess-aaa");
assert!(artifact.modified.is_some() && artifact.size.is_some());
crate::cache::write_cached(&derived.cache_id, &derived.doc, true).unwrap();
let config = Config {
home: Some(home.to_path_buf()),
toolpath_config_dir: Some(config_dir.to_path_buf()),
..Config::default()
};
record_artifact(&config, artifact, &derived.cache_id).unwrap();
let (_, outcome) =
sync_bundle(config_dir, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap()
[0];
assert_eq!(
(
outcome.new,
outcome.updated,
outcome.unchanged,
outcome.failed
),
(0, 0, 1, 0)
);
});
}
#[test]
fn project_under_scopes_path_keyed_enumeration() {
with_cfg(|home, config_dir| {
write_claude_session(home, "-scope-alpha", "aaaa1111-x", "In alpha");
write_claude_session(home, "-scope-beta", "bbbb2222-x", "In beta");
let bundle = claude_bundle(home);
let (_, scoped) = sync_bundle(
config_dir,
&bundle,
&[ArtifactType::Claude],
Some(Path::new("/scope/alpha")),
&mut (),
)
.unwrap()[0];
assert_eq!((scoped.new, scoped.out_of_scope), (1, 0));
let manifest = load_manifest(config_dir).unwrap();
assert!(
!manifest["claude"].contains_key("bbbb2222-x"),
"pruned projects must not be enumerated or recorded"
);
let (_, full) =
sync_bundle(config_dir, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap()
[0];
assert_eq!((full.new, full.unchanged), (1, 1));
});
}
fn codex_bundle(home: &Path, cwd: &str) -> HarnessBundle {
let codex_dir = home.join(".codex");
let dir = codex_dir.join("sessions/2026/05/07");
std::fs::create_dir_all(&dir).unwrap();
let meta = format!(
r#"{{"timestamp":"2026-05-07T00:00:00Z","type":"session_meta","payload":{{"id":"00000000-0000-0000-0000-0000000000aa","timestamp":"2026-05-07T00:00:00Z","cwd":"{cwd}","originator":"codex-tui","cli_version":"test","source":"cli","model_provider":"openai"}}}}"#
);
let user = r#"{"timestamp":"2026-05-07T00:00:01Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}}"#;
std::fs::write(
dir.join("rollout-2026-05-07T00-00-00-00000000-0000-0000-0000-0000000000aa.jsonl"),
format!("{meta}\n{user}\n"),
)
.unwrap();
let resolver = toolpath_codex::PathResolver::new().with_codex_dir(&codex_dir);
HarnessBundle {
codex: Some(toolpath_codex::CodexConvo::with_resolver(resolver)),
..Default::default()
}
}
#[test]
fn out_of_scope_codex_peek_is_memoized_then_scope_match_derives() {
with_cfg(|home, config_dir| {
let bundle = codex_bundle(home, "/work/proj");
let (_, out) = sync_bundle(
config_dir,
&bundle,
&[ArtifactType::Codex],
Some(Path::new("/elsewhere")),
&mut (),
)
.unwrap()[0];
assert_eq!((out.new, out.out_of_scope), (0, 1));
let rec =
load_manifest(config_dir).unwrap()["codex"]["00000000-0000-0000-0000-0000000000aa"]
.clone();
assert_eq!(
rec.path.as_deref(),
Some("/work/proj"),
"peeked cwd memoized"
);
assert!(rec.cache_id.is_none(), "known, not materialized");
let (_, hit) = sync_bundle(
config_dir,
&bundle,
&[ArtifactType::Codex],
Some(Path::new("/work/proj")),
&mut (),
)
.unwrap()[0];
assert_eq!((hit.new, hit.updated, hit.out_of_scope), (0, 1, 0));
let rec =
load_manifest(config_dir).unwrap()["codex"]["00000000-0000-0000-0000-0000000000aa"]
.clone();
assert!(rec.cache_id.is_some(), "materialized now");
assert_eq!(
rec.path.as_deref(),
Some("/work/proj"),
"deriving must not clobber the memoized peeked cwd"
);
});
}
fn copilot_bundle(home: &Path, id: &str, cwd: &str) -> HarnessBundle {
let copilot_dir = home.join(".copilot");
let dir = copilot_dir.join("session-state").join(id);
std::fs::create_dir_all(&dir).unwrap();
let start = format!(
r#"{{"type":"session.start","timestamp":"2026-07-01T00:00:00Z","data":{{"copilotVersion":"1.0.67","context":{{"cwd":"{cwd}"}}}}}}"#
);
let user =
r#"{"type":"user.message","timestamp":"2026-07-01T00:00:01Z","data":{"content":"hi"}}"#;
std::fs::write(dir.join("events.jsonl"), format!("{start}\n{user}\n")).unwrap();
let resolver = toolpath_copilot::PathResolver::new().with_copilot_dir(&copilot_dir);
HarnessBundle {
copilot: Some(toolpath_copilot::CopilotConvo::with_resolver(resolver)),
..Default::default()
}
}
#[test]
fn copilot_syncs_and_scopes_via_memoized_peek() {
with_cfg(|home, config_dir| {
let bundle = copilot_bundle(home, "sess-cp", "/work/proj");
let (_, out) = sync_bundle(
config_dir,
&bundle,
&[ArtifactType::Copilot],
Some(Path::new("/elsewhere")),
&mut (),
)
.unwrap()[0];
assert_eq!((out.new, out.out_of_scope), (0, 1));
let rec = load_manifest(config_dir).unwrap()["copilot"]["sess-cp"].clone();
assert_eq!(rec.path.as_deref(), Some("/work/proj"));
assert!(rec.cache_id.is_none());
let (_, hit) = sync_bundle(
config_dir,
&bundle,
&[ArtifactType::Copilot],
Some(Path::new("/work")),
&mut (),
)
.unwrap()[0];
assert_eq!((hit.updated, hit.out_of_scope), (1, 0));
let (_, again) =
sync_bundle(config_dir, &bundle, &[ArtifactType::Copilot], None, &mut ()).unwrap()
[0];
assert_eq!(again.unchanged, 1);
});
}
#[test]
fn evicted_cache_entry_rematerializes_on_next_sync() {
with_cfg(|home, config_dir| {
write_claude_session(home, "-test-project", "sess-aaa", "Add a feature");
let bundle = claude_bundle(home);
sync_bundle(config_dir, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap();
let cache_id = load_manifest(config_dir).unwrap()["claude"]["sess-aaa"]
.cache_id
.clone()
.unwrap();
crate::cache::remove_cached(&cache_id).unwrap();
evict_cache_id(config_dir, &cache_id).unwrap();
assert!(
load_manifest(config_dir).unwrap()["claude"]["sess-aaa"]
.cache_id
.is_none()
);
let (_, outcome) =
sync_bundle(config_dir, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap()
[0];
assert_eq!((outcome.new, outcome.updated), (0, 1));
assert!(
crate::cache::cache_path(&cache_id).unwrap().exists(),
"evicted artifact re-materializes"
);
});
}
#[test]
fn manually_deleted_doc_is_restored_even_with_stale_record() {
with_cfg(|home, config_dir| {
write_claude_session(home, "-test-project", "sess-aaa", "Add a feature");
let bundle = claude_bundle(home);
sync_bundle(config_dir, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap();
let cache_id = load_manifest(config_dir).unwrap()["claude"]["sess-aaa"]
.cache_id
.clone()
.unwrap();
let doc = crate::cache::cache_path(&cache_id).unwrap();
std::fs::remove_file(&doc).unwrap();
let (_, outcome) =
sync_bundle(config_dir, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap()
[0];
assert_eq!((outcome.new, outcome.updated), (0, 1));
assert!(doc.exists());
});
}
#[test]
fn fresh_cache_id_tracks_source_and_eviction() {
with_cfg(|home, config_dir| {
write_claude_session(home, "-test-project", "sess-aaa", "Add a feature");
let bundle = claude_bundle(home);
assert!(
fresh_cache_id(
&bundle,
ArtifactType::Claude,
Some("/test/project"),
"sess-aaa"
)
.is_none()
);
sync_bundle(config_dir, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap();
let cache_id = fresh_cache_id(
&bundle,
ArtifactType::Claude,
Some("/test/project"),
"sess-aaa",
)
.expect("synced artifact is fresh");
let file = home.join(".claude/projects/-test-project/sess-aaa.jsonl");
let mut body = std::fs::read_to_string(&file).unwrap();
body.push_str(
r#"{"type":"user","uuid":"u-2","timestamp":"2024-01-02T00:05:00Z","cwd":"/test/project","message":{"role":"user","content":"more"}}"#,
);
body.push('\n');
std::fs::write(&file, body).unwrap();
assert!(
fresh_cache_id(
&bundle,
ArtifactType::Claude,
Some("/test/project"),
"sess-aaa"
)
.is_none()
);
sync_bundle(config_dir, &bundle, &[ArtifactType::Claude], None, &mut ()).unwrap();
assert!(
fresh_cache_id(
&bundle,
ArtifactType::Claude,
Some("/test/project"),
"sess-aaa"
)
.is_some()
);
crate::cache::remove_cached(&cache_id).unwrap();
evict_cache_id(config_dir, &cache_id).unwrap();
assert!(
fresh_cache_id(
&bundle,
ArtifactType::Claude,
Some("/test/project"),
"sess-aaa"
)
.is_none()
);
});
}
#[test]
fn copilot_peek_accepts_top_level_cwd() {
with_cfg(|home, config_dir| {
let copilot_dir = home.join(".copilot");
let dir = copilot_dir.join("session-state").join("sess-legacy");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("events.jsonl"),
concat!(
r#"{"type":"session.start","data":{"cwd":"/work/proj"}}"#,
"\n",
r#"{"type":"user.message","data":{"content":"hi"}}"#,
"\n"
),
)
.unwrap();
let resolver = toolpath_copilot::PathResolver::new().with_copilot_dir(&copilot_dir);
let bundle = HarnessBundle {
copilot: Some(toolpath_copilot::CopilotConvo::with_resolver(resolver)),
..Default::default()
};
let (_, out) = sync_bundle(
config_dir,
&bundle,
&[ArtifactType::Copilot],
Some(Path::new("/elsewhere")),
&mut (),
)
.unwrap()[0];
assert_eq!(out.out_of_scope, 1);
let rec = load_manifest(config_dir).unwrap()["copilot"]["sess-legacy"].clone();
assert_eq!(rec.path.as_deref(), Some("/work/proj"));
});
}
#[test]
fn newest_first_orders_by_mtime_with_unstamped_last() {
let mut old = make_ref(ArtifactType::Claude, "old");
old.modified = Some("2026-01-01T00:00:00Z".parse().unwrap());
let mut new = make_ref(ArtifactType::Claude, "new");
new.modified = Some("2026-07-01T00:00:00Z".parse().unwrap());
let unstamped = make_ref(ArtifactType::Claude, "unstamped");
let stubs = vec![old, unstamped, new];
let ids: Vec<&str> = newest_first(&stubs).iter().map(|s| s.id.as_str()).collect();
assert_eq!(ids, vec!["new", "old", "unstamped"]);
}
}