use anyhow::{Result, anyhow};
use chrono::{DateTime, Utc};
use std::path::{Path, PathBuf};
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, project_under: Option<&Path>) -> Vec<ArtifactRef>;
fn stamp(&self, project: Option<&str>, id: &str) -> Option<Stamp>;
fn derive(&self, artifact: &ArtifactRef) -> Result<DerivedDoc>;
fn peek_dir(&self, _id: &str) -> Option<String> {
None
}
fn in_scope(&self, dir: &str, project_under: &Path) -> bool {
dir_in_scope(dir, project_under)
}
}
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))
}
fn canonicalize_or_self(p: &Path) -> PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
}
fn dir_in_scope(dir: &str, project_under: &Path) -> bool {
let d = canonicalize_or_self(Path::new(dir));
d.starts_with(canonicalize_or_self(project_under)) || d.starts_with(project_under)
}
struct ClaudeSource<'a>(&'a toolpath_claude::ClaudeConvo);
impl ArtifactSource for ClaudeSource<'_> {
fn enumerate(&self, project_under: Option<&Path>) -> 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 {
if let Some(project_under) = project_under
&& !claude_project_in_scope(&project, project_under)
{
continue;
}
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)
}
fn in_scope(&self, dir: &str, project_under: &Path) -> bool {
claude_project_in_scope(dir, project_under)
}
}
fn claude_project_in_scope(project: &str, project_under: &Path) -> bool {
fn slug(s: &str) -> String {
s.replace(['/', '_', '.'], "-")
}
let p = slug(project);
[
slug(&project_under.to_string_lossy()),
slug(&canonicalize_or_self(project_under).to_string_lossy()),
]
.iter()
.any(|parent| p == *parent || p.starts_with(&format!("{parent}-")))
}
struct GeminiSource<'a>(&'a toolpath_gemini::GeminiConvo);
impl ArtifactSource for GeminiSource<'_> {
fn enumerate(&self, project_under: Option<&Path>) -> 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 {
if let Some(project_under) = project_under
&& !dir_in_scope(&project, project_under)
{
continue;
}
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, _project_under: Option<&Path>) -> 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)
}
fn peek_dir(&self, id: &str) -> Option<String> {
use std::io::{BufRead, BufReader};
let file = self.0.resolver().find_rollout_file(id).ok()?;
let f = std::fs::File::open(file).ok()?;
let mut line = String::new();
BufReader::new(f).read_line(&mut line).ok()?;
let v: serde_json::Value = serde_json::from_str(&line).ok()?;
Some(v.get("payload")?.get("cwd")?.as_str()?.to_string())
}
}
struct OpencodeSource<'a>(&'a toolpath_opencode::OpencodeConvo);
impl ArtifactSource for OpencodeSource<'_> {
fn enumerate(&self, _project_under: Option<&Path>) -> 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, _project_under: Option<&Path>) -> 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, project_under: Option<&Path>) -> 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 {
if let Some(project_under) = project_under
&& !pi_project_in_scope(&project, project_under)
{
continue;
}
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)
}
fn in_scope(&self, dir: &str, project_under: &Path) -> bool {
pi_project_in_scope(dir, project_under)
}
}
fn pi_project_in_scope(project: &str, project_under: &Path) -> bool {
fn enc(s: &str) -> String {
s.replace('/', "-")
}
let p = enc(project);
[
enc(&project_under.to_string_lossy()),
enc(&canonicalize_or_self(project_under).to_string_lossy()),
]
.iter()
.any(|parent| p == *parent || p.starts_with(&format!("{parent}-")))
}
struct CopilotSource<'a>(&'a toolpath_copilot::CopilotConvo);
impl ArtifactSource for CopilotSource<'_> {
fn enumerate(&self, _project_under: Option<&Path>) -> 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)
}
fn peek_dir(&self, id: &str) -> Option<String> {
use std::io::{BufRead, BufReader};
let file = self.0.resolver().events_file(id).ok()?;
let f = std::fs::File::open(file).ok()?;
BufReader::new(f).lines().take(10).find_map(|line| {
let v: serde_json::Value = serde_json::from_str(&line.ok()?).ok()?;
["data", "payload"].iter().find_map(|env| {
let payload = v.get(env)?;
[payload.get("context").unwrap_or(payload), payload]
.iter()
.find_map(|scope| {
["cwd", "workingDirectory", "working_dir"]
.iter()
.find_map(|k| Some(scope.get(k)?.as_str()?.to_string()))
})
})
})
}
}
#[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 config = crate::config::Config {
home: Some(PathBuf::from("/home/jailed")),
..Default::default()
};
let with_claude = HarnessBundle {
claude: Some(crate::providers::claude_convo(&config)),
..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"
);
}
#[test]
fn pi_scope_matching_survives_hyphenated_paths() {
assert!(pi_project_in_scope(
"/Users/b/my/repo",
Path::new("/Users/b/my-repo")
));
assert!(pi_project_in_scope(
"/Users/b/my/repo/sub",
Path::new("/Users/b/my-repo")
));
assert!(!pi_project_in_scope(
"/Users/b/other",
Path::new("/Users/b/my-repo")
));
}
}