use anyhow::{Result, anyhow};
use chrono::{DateTime, Utc};
use crate::artifact::{ArtifactRef, ArtifactType, claude_chain_stamp, stat_stamp};
use crate::derive::{self, DerivedDoc};
use crate::harness::{
HarnessBundle, is_not_found_claude, is_not_found_codex, is_not_found_cursor,
is_not_found_gemini, is_not_found_opencode, is_not_found_pi,
};
pub(crate) type Stamp = (Option<DateTime<Utc>>, Option<u64>);
pub(crate) trait ArtifactSource {
fn enumerate(&self) -> Vec<ArtifactRef>;
fn stamp(&self, project: Option<&str>, id: &str) -> Option<Stamp>;
fn derive(&self, artifact: &ArtifactRef) -> Result<DerivedDoc>;
}
pub(crate) fn source_for<'a>(
bundle: &'a HarnessBundle,
t: ArtifactType,
) -> Option<Box<dyn ArtifactSource + 'a>> {
match t {
ArtifactType::Claude => Some(Box::new(ClaudeSource(bundle.claude.as_ref()?))),
ArtifactType::Gemini => Some(Box::new(GeminiSource(bundle.gemini.as_ref()?))),
ArtifactType::Codex => Some(Box::new(CodexSource(bundle.codex.as_ref()?))),
ArtifactType::Opencode => Some(Box::new(OpencodeSource(bundle.opencode.as_ref()?))),
ArtifactType::Cursor => Some(Box::new(CursorSource(bundle.cursor.as_ref()?))),
ArtifactType::Pi => Some(Box::new(PiSource(bundle.pi.as_ref()?))),
ArtifactType::Copilot => Some(Box::new(CopilotSource(bundle.copilot.as_ref()?))),
ArtifactType::Git => None,
}
}
fn require_path(artifact: &ArtifactRef) -> Result<&str> {
artifact
.path
.as_deref()
.ok_or_else(|| anyhow!("artifact {} has no path", artifact.id))
}
struct ClaudeSource<'a>(&'a toolpath_claude::ClaudeConvo);
impl ArtifactSource for ClaudeSource<'_> {
fn enumerate(&self) -> Vec<ArtifactRef> {
let mut out = Vec::new();
let projects = match self.0.list_projects() {
Ok(ps) => ps,
Err(e) if is_not_found_claude(&e) => return out,
Err(e) => {
eprintln!("warning: claude enumeration failed: {e}");
return out;
}
};
for project in projects {
let heads = match self.0.list_conversations(&project) {
Ok(h) => h,
Err(e) => {
eprintln!("warning: claude project {project} failed: {e}");
continue;
}
};
for head in heads {
let (modified, size) = claude_chain_stamp(self.0, &project, &head);
out.push(ArtifactRef {
artifact_type: ArtifactType::Claude,
id: head,
path: Some(project.clone()),
modified,
size,
});
}
}
out
}
fn stamp(&self, project: Option<&str>, id: &str) -> Option<Stamp> {
Some(claude_chain_stamp(self.0, project?, id))
}
fn derive(&self, artifact: &ArtifactRef) -> Result<DerivedDoc> {
derive::derive_claude_session_with(self.0, require_path(artifact)?, &artifact.id)
}
}
struct GeminiSource<'a>(&'a toolpath_gemini::GeminiConvo);
impl ArtifactSource for GeminiSource<'_> {
fn enumerate(&self) -> Vec<ArtifactRef> {
let mut out = Vec::new();
let projects = match self.0.list_projects() {
Ok(ps) => ps,
Err(e) if is_not_found_gemini(&e) => return out,
Err(e) => {
eprintln!("warning: gemini enumeration failed: {e}");
return out;
}
};
for project in projects {
let entries = match self.0.resolver().list_session_entries(&project) {
Ok(entries) => entries,
Err(e) => {
eprintln!("warning: gemini project {project} failed: {e}");
continue;
}
};
for entry in entries {
let (modified, size) = stat_stamp(&entry.path);
out.push(ArtifactRef {
artifact_type: ArtifactType::Gemini,
id: entry.session_uuid.unwrap_or(entry.id),
path: Some(project.clone()),
modified,
size,
});
}
}
out
}
fn stamp(&self, project: Option<&str>, id: &str) -> Option<Stamp> {
let entries = self.0.resolver().list_session_entries(project?).ok()?;
let entry = entries
.into_iter()
.find(|e| e.id == id || e.session_uuid.as_deref() == Some(id))?;
Some(stat_stamp(&entry.path))
}
fn derive(&self, artifact: &ArtifactRef) -> Result<DerivedDoc> {
derive::derive_gemini_session_with(self.0, require_path(artifact)?, &artifact.id)
}
}
struct CodexSource<'a>(&'a toolpath_codex::CodexConvo);
impl ArtifactSource for CodexSource<'_> {
fn enumerate(&self) -> Vec<ArtifactRef> {
let mut out = Vec::new();
let files = match self.0.io().list_rollout_files() {
Ok(f) => f,
Err(e) if is_not_found_codex(&e) => return out,
Err(e) => {
eprintln!("warning: codex enumeration failed: {e}");
return out;
}
};
for file in files {
let Some(stem) = file.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let id = toolpath_codex::session_id_from_stem(stem).to_string();
let (modified, size) = stat_stamp(&file);
out.push(ArtifactRef {
artifact_type: ArtifactType::Codex,
id,
path: None,
modified,
size,
});
}
out
}
fn stamp(&self, _project: Option<&str>, id: &str) -> Option<Stamp> {
let file = self.0.resolver().find_rollout_file(id).ok()?;
Some(stat_stamp(&file))
}
fn derive(&self, artifact: &ArtifactRef) -> Result<DerivedDoc> {
derive::derive_codex_session_with(self.0, &artifact.id)
}
}
struct OpencodeSource<'a>(&'a toolpath_opencode::OpencodeConvo);
impl ArtifactSource for OpencodeSource<'_> {
fn enumerate(&self) -> Vec<ArtifactRef> {
let mut out = Vec::new();
let sessions = match self.0.io().list_sessions(None) {
Ok(s) => s,
Err(e) if is_not_found_opencode(&e) => return out,
Err(e) => {
eprintln!("warning: opencode enumeration failed: {e}");
return out;
}
};
for s in sessions {
out.push(ArtifactRef {
artifact_type: ArtifactType::Opencode,
modified: s.last_activity(),
path: Some(s.directory.to_string_lossy().into_owned()),
id: s.id,
size: None,
});
}
out
}
fn stamp(&self, _project: Option<&str>, id: &str) -> Option<Stamp> {
let sessions = self.0.io().list_sessions(None).ok()?;
let session = sessions.into_iter().find(|s| s.id == id)?;
Some((session.last_activity(), None))
}
fn derive(&self, artifact: &ArtifactRef) -> Result<DerivedDoc> {
derive::derive_opencode_session_with(self.0, &artifact.id, false)
}
}
struct CursorSource<'a>(&'a toolpath_cursor::CursorConvo);
impl ArtifactSource for CursorSource<'_> {
fn enumerate(&self) -> Vec<ArtifactRef> {
let mut out = Vec::new();
let listings = match self.0.io().list_composers() {
Ok(l) => l,
Err(e) if is_not_found_cursor(&e) => return out,
Err(e) => {
eprintln!("warning: cursor enumeration failed: {e}");
return out;
}
};
for l in listings.into_iter().filter(|l| l.has_bubbles) {
out.push(ArtifactRef {
artifact_type: ArtifactType::Cursor,
modified: l.head.last_updated_at_utc(),
path: l
.head
.workspace_path()
.map(|p| p.to_string_lossy().into_owned()),
id: l.head.composer_id,
size: None,
});
}
out
}
fn stamp(&self, _project: Option<&str>, id: &str) -> Option<Stamp> {
let headers = self.0.io().read_composer_headers().ok()?;
let composer = headers
.all_composers
.into_iter()
.find(|c| c.composer_id == id)?;
Some((composer.last_updated_at_utc(), None))
}
fn derive(&self, artifact: &ArtifactRef) -> Result<DerivedDoc> {
derive::derive_cursor_session_with(self.0, &artifact.id)
}
}
struct PiSource<'a>(&'a toolpath_pi::PiConvo);
impl ArtifactSource for PiSource<'_> {
fn enumerate(&self) -> Vec<ArtifactRef> {
let mut out = Vec::new();
let projects = match self.0.list_projects() {
Ok(ps) => ps,
Err(e) if is_not_found_pi(&e) => return out,
Err(e) => {
eprintln!("warning: pi enumeration failed: {e}");
return out;
}
};
for project in projects {
let files = match toolpath_pi::reader::list_session_files(self.0.resolver(), &project) {
Ok(f) => f,
Err(e) => {
eprintln!("warning: pi project {project} failed: {e}");
continue;
}
};
for file in files {
let header_id = toolpath_pi::reader::peek_header(&file)
.ok()
.map(|h| h.id)
.filter(|id| !id.is_empty());
let stem_id = file
.file_stem()
.and_then(|s| s.to_str())
.and_then(|s| s.split_once('_'))
.map(|(_, rest)| rest.to_string());
let Some(id) = header_id.or(stem_id) else {
continue;
};
let (modified, size) = stat_stamp(&file);
out.push(ArtifactRef {
artifact_type: ArtifactType::Pi,
id,
path: Some(project.clone()),
modified,
size,
});
}
}
out
}
fn stamp(&self, project: Option<&str>, id: &str) -> Option<Stamp> {
let files = toolpath_pi::reader::list_session_files(self.0.resolver(), project?).ok()?;
let file = files.into_iter().find(|f| {
toolpath_pi::reader::peek_header(f).is_ok_and(|h| h.id == id)
|| f.file_stem()
.and_then(|stem| stem.to_str())
.and_then(|stem| stem.split_once('_'))
.is_some_and(|(_, rest)| rest == id)
})?;
Some(stat_stamp(&file))
}
fn derive(&self, artifact: &ArtifactRef) -> Result<DerivedDoc> {
derive::derive_pi_session_with(self.0, require_path(artifact)?, &artifact.id)
}
}
struct CopilotSource<'a>(&'a toolpath_copilot::CopilotConvo);
impl ArtifactSource for CopilotSource<'_> {
fn enumerate(&self) -> Vec<ArtifactRef> {
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
let dirs = [
self.0.resolver().session_state_dir(),
self.0.resolver().legacy_session_state_dir(),
];
for dir in dirs.into_iter().flatten() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let Some(id) = entry.file_name().to_str().map(String::from) else {
continue;
};
let events = entry.path().join("events.jsonl");
if !events.exists() || !seen.insert(id.clone()) {
continue;
}
let (modified, size) = stat_stamp(&events);
out.push(ArtifactRef {
artifact_type: ArtifactType::Copilot,
id,
path: None,
modified,
size,
});
}
}
out
}
fn stamp(&self, _project: Option<&str>, id: &str) -> Option<Stamp> {
let file = self.0.resolver().events_file(id).ok()?;
Some(stat_stamp(&file))
}
fn derive(&self, artifact: &ArtifactRef) -> Result<DerivedDoc> {
derive::derive_copilot_session_with(self.0, &artifact.id)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn source_for_missing_provider_or_git_is_none() {
let empty = HarnessBundle::default();
assert!(source_for(&empty, ArtifactType::Claude).is_none());
assert!(source_for(&empty, ArtifactType::Git).is_none());
let with_claude = HarnessBundle {
claude: Some(toolpath_claude::ClaudeConvo::new()),
..Default::default()
};
assert!(source_for(&with_claude, ArtifactType::Claude).is_some());
assert!(
source_for(&with_claude, ArtifactType::Git).is_none(),
"git is recorded by `p import`, never enumerated or derived by sync"
);
}
}