mod aider;
mod claude_code;
mod cline;
mod codex;
mod continue_dev;
mod gajae;
mod gemini;
mod gptme;
pub mod harness;
mod opencode;
mod prodex;
pub use claude_code::ClaudeCode;
pub use codex::Codex;
pub use prodex::thread_url_for_task as prodex_thread_url;
use crate::model::{Session, StoreReport};
use anyhow::Result;
use chrono::{DateTime, Utc};
use std::path::{Path, PathBuf};
pub struct Store {
pub keys: Vec<(String, i64)>,
pub files: Vec<PathBuf>,
pub had_error: bool,
}
pub struct Discovered {
pub files: Vec<PathBuf>,
pub had_error: bool,
}
impl From<Vec<PathBuf>> for Discovered {
fn from(files: Vec<PathBuf>) -> Self {
Discovered {
files,
had_error: false,
}
}
}
pub(crate) fn ok_or_flag<T, E>(r: std::result::Result<T, E>, had_error: &mut bool) -> Option<T> {
match r {
Ok(v) => Some(v),
Err(_) => {
*had_error = true;
None
}
}
}
pub trait Adapter {
fn name(&self) -> &'static str;
fn root(&self) -> Option<PathBuf>;
fn discover(&self) -> Discovered;
fn parse(&self, path: &Path) -> Result<Session>;
fn store(&self) -> Option<Store> {
None
}
fn parse_key(&self, _key: &str) -> Result<Session> {
anyhow::bail!("this adapter is not a shared store")
}
fn reconcile_scope(&self) -> Option<String> {
None
}
}
pub(crate) fn root_scope(root: Option<&Path>) -> Option<String> {
let root = root?;
let mut prefix = root.to_string_lossy().into_owned();
if !prefix.ends_with(std::path::MAIN_SEPARATOR) {
prefix.push(std::path::MAIN_SEPARATOR);
}
Some(prefix)
}
pub fn all() -> Vec<Box<dyn Adapter>> {
vec![
Box::new(ClaudeCode::default()),
Box::new(Codex::default()),
Box::new(gemini::Gemini),
Box::new(opencode::OpenCode),
Box::new(cline::Cline),
Box::new(cline::RooCode),
Box::new(cline::KiloCode),
Box::new(gajae::GajaeCode),
Box::new(continue_dev::Continue),
Box::new(gptme::Gptme),
Box::new(aider::Aider::default()),
Box::new(prodex::Prodex),
]
}
pub fn by_name(name: &str) -> Option<Box<dyn Adapter>> {
all().into_iter().find(|a| a.name() == name)
}
pub fn report(adapter: &dyn Adapter) -> Option<StoreReport> {
let root = adapter.root()?;
if let Some(store) = adapter.store() {
if store.keys.is_empty() {
return None; }
let bytes = store
.files
.iter()
.filter_map(|f| f.metadata().ok())
.map(|m| m.len())
.sum();
let oldest = store
.keys
.iter()
.map(|(_, t)| *t)
.filter(|t| *t > 0)
.min()
.and_then(DateTime::from_timestamp_millis);
let newest = store
.keys
.iter()
.map(|(_, t)| *t)
.filter(|t| *t > 0)
.max()
.and_then(DateTime::from_timestamp_millis);
return Some(StoreReport {
tool: adapter.name(),
root,
files: store.keys.len(),
bytes,
oldest,
newest,
});
}
if !root.exists() {
return None;
}
let files = adapter.discover().files;
let mut bytes: u64 = 0;
let mut oldest: Option<DateTime<Utc>> = None;
let mut newest: Option<DateTime<Utc>> = None;
let mut count = 0usize;
for f in &files {
let Ok(meta) = f.metadata() else { continue };
count += 1;
bytes += meta.len();
if let Ok(modified) = meta.modified() {
let t: DateTime<Utc> = modified.into();
if oldest.is_none_or(|o| t < o) {
oldest = Some(t);
}
if newest.is_none_or(|n| t > n) {
newest = Some(t);
}
}
}
if count == 0 {
return None; }
Some(StoreReport {
tool: adapter.name(),
root,
files: count,
bytes,
oldest,
newest,
})
}
pub(crate) fn title_from_messages(messages: &[crate::model::Message]) -> String {
messages
.iter()
.find(|m| {
m.role == crate::model::Role::User
&& !m.text.trim_start().starts_with('<')
&& !m.text.trim().is_empty()
})
.map(|m| redacted_truncate(&m.text, 80))
.unwrap_or_else(|| "(no user prompt)".into())
}
pub(crate) fn redacted_truncate(text: &str, max: usize) -> String {
crate::util::truncate(&crate::redact::redact(text), max)
}
pub(crate) fn redacted_first_line(text: &str, max: usize) -> String {
let redacted = crate::redact::redact(text);
redacted
.lines()
.next()
.unwrap_or("")
.chars()
.take(max)
.collect()
}
pub(crate) fn parse_ts(s: &str) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(s)
.ok()
.map(|t| t.with_timezone(&Utc))
}
pub(crate) fn clean_path(p: &str) -> Option<String> {
let p = p.trim();
(!p.is_empty() && !p.contains('\n') && p.len() <= 4096).then(|| p.to_string())
}
pub(crate) fn dedup_paths(paths: Vec<String>) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
paths
.into_iter()
.filter_map(|p| clean_path(&p))
.filter(|p| seen.insert(p.clone()))
.collect()
}