use rayon::prelude::*;
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
use crate::clients::ClientId;
use crate::sessions::{normalize_workspace_key, workspace_label_from_key};
use serde::{Deserialize, Serialize};
use serde_json::Value;
fn warn_if_escapes_home(home: &Path, client_id: ClientId, path: &Path) {
if !path.starts_with(home) {
tracing::warn!(
client = client_id.as_str(),
path = %path.display(),
home = %home.display(),
"extra scan path is outside $HOME — verify this is intentional"
);
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ScannerSettings {
#[serde(default)]
pub opencode_db_paths: Vec<PathBuf>,
#[serde(default)]
pub extra_scan_paths: BTreeMap<String, Vec<PathBuf>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CrushDbSource {
pub db_path: PathBuf,
pub workspace_key: Option<String>,
pub workspace_label: Option<String>,
}
#[derive(Debug)]
pub struct ScanResult {
pub files: [Vec<PathBuf>; ClientId::COUNT],
pub opencode_dbs: Vec<PathBuf>,
pub copilot_desktop_db: Option<PathBuf>,
pub synthetic_db: Option<PathBuf>,
pub kilo_db: Option<PathBuf>,
pub hermes_db: Option<PathBuf>,
pub goose_db: Option<PathBuf>,
pub zed_db: Option<PathBuf>,
pub kiro_db: Option<PathBuf>,
pub crush_dbs: Vec<CrushDbSource>,
pub zcode_db: Option<PathBuf>,
pub openclaw_dbs: Vec<PathBuf>,
pub micode_dbs: Vec<PathBuf>,
pub opencode_json_dir: Option<PathBuf>,
pub devin_dbs: Vec<PathBuf>,
pub copilot_vscode_sessions: Vec<PathBuf>,
}
impl Default for ScanResult {
fn default() -> Self {
Self {
files: std::array::from_fn(|_| Vec::new()),
opencode_dbs: Vec::new(),
copilot_desktop_db: None,
synthetic_db: None,
kilo_db: None,
hermes_db: None,
goose_db: None,
zed_db: None,
kiro_db: None,
crush_dbs: Vec::new(),
zcode_db: None,
openclaw_dbs: Vec::new(),
micode_dbs: Vec::new(),
opencode_json_dir: None,
devin_dbs: Vec::new(),
copilot_vscode_sessions: Vec::new(),
}
}
}
impl ScanResult {
pub fn get(&self, client: ClientId) -> &Vec<PathBuf> {
&self.files[client as usize]
}
pub fn get_mut(&mut self, client: ClientId) -> &mut Vec<PathBuf> {
&mut self.files[client as usize]
}
pub fn total_files(&self) -> usize {
self.files.iter().map(|v| v.len()).sum()
}
pub fn all_files(&self) -> Vec<(ClientId, PathBuf)> {
let mut result = Vec::with_capacity(self.total_files());
for client in ClientId::iter() {
for path in self.get(client) {
result.push((client, path.clone()));
}
}
result
}
pub fn hermes_db_paths(&self) -> Vec<PathBuf> {
let mut paths = Vec::new();
let mut seen: HashSet<PathBuf> = HashSet::new();
let mut push = |path: &Path| {
let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
if seen.insert(key) {
paths.push(path.to_path_buf());
}
};
if let Some(path) = &self.hermes_db {
push(path);
}
for path in self.get(ClientId::Hermes) {
push(path);
}
paths
}
pub fn zed_db_paths(&self) -> Vec<PathBuf> {
let mut paths = Vec::new();
let mut seen: HashSet<PathBuf> = HashSet::new();
let mut push = |path: &Path| {
let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
if seen.insert(key) {
paths.push(path.to_path_buf());
}
};
if let Some(path) = &self.zed_db {
push(path);
}
for path in self.get(ClientId::Zed) {
push(path);
}
paths
}
}
pub fn headless_roots_with_env_strategy(home_dir: &str, use_env_roots: bool) -> Vec<PathBuf> {
if use_env_roots {
if let Ok(path) = std::env::var("TOKMESH_HEADLESS_DIR") {
return vec![PathBuf::from(path)];
}
}
vec![PathBuf::from(format!(
"{}/.config/tokmesh/headless",
home_dir
))]
}
pub fn headless_roots(home_dir: &str) -> Vec<PathBuf> {
headless_roots_with_env_strategy(home_dir, true)
}
pub fn copilot_exporter_path_with_env_strategy(use_env_roots: bool) -> Option<PathBuf> {
if !use_env_roots {
return None;
}
let path = std::env::var("COPILOT_OTEL_FILE_EXPORTER_PATH").ok()?;
let trimmed = path.trim();
if trimmed.is_empty() {
return None;
}
Some(PathBuf::from(trimmed))
}
pub fn copilot_exporter_path() -> Option<PathBuf> {
copilot_exporter_path_with_env_strategy(true)
}
fn devin_cli_additional_roots(home_dir: &str, use_env_roots: bool) -> Vec<PathBuf> {
let mut roots = Vec::new();
if cfg!(target_os = "windows") && use_env_roots {
if let Some(app_data) = std::env::var_os("APPDATA").filter(|value| !value.is_empty()) {
roots.push(PathBuf::from(app_data).join("devin/cli"));
}
}
roots.push(PathBuf::from(home_dir).join("AppData/Roaming/devin/cli"));
roots
}
const SENPI_SESSION_HEADER_MAX_BYTES: u64 = 8 * 1024;
#[derive(Debug, PartialEq)]
enum OmoTaskState {
Unset,
StateDir(PathBuf),
DefaultLayout,
}
fn omo_task_state(omo_dir: &Path) -> OmoTaskState {
for filename in ["omo.jsonc", "omo.json"] {
let Ok(contents) = std::fs::read_to_string(omo_dir.join(filename)) else {
continue;
};
let Some(config) = crate::opencode_model_name::parse_jsonc(&contents) else {
continue;
};
let Some(task) = config.get("task") else {
return OmoTaskState::Unset;
};
let state_dir = task
.get("state_dir")
.and_then(Value::as_str)
.map(PathBuf::from)
.filter(|path| path.is_absolute());
return match state_dir {
Some(path) => OmoTaskState::StateDir(path),
None => OmoTaskState::DefaultLayout,
};
}
OmoTaskState::Unset
}
fn senpi_omo_children_root(project_dir: &Path, user_state_dir: Option<&Path>) -> PathBuf {
let default_layout = || project_dir.join(".omo").join("senpi-task");
let state_dir = match omo_task_state(&project_dir.join(".omo")) {
OmoTaskState::StateDir(path) => path,
OmoTaskState::DefaultLayout => default_layout(),
OmoTaskState::Unset => user_state_dir
.map(PathBuf::from)
.unwrap_or_else(default_layout),
};
state_dir.join("children")
}
fn discover_senpi_omo_children_roots(
sessions_root: &Path,
user_state_dir: Option<&Path>,
) -> Vec<PathBuf> {
let entries = match std::fs::read_dir(sessions_root) {
Ok(entries) => entries,
Err(_) => return Vec::new(),
};
let mut roots: Vec<PathBuf> = entries
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry
.file_type()
.is_ok_and(|kind| kind.is_dir() || (kind.is_symlink() && entry.path().is_dir()))
})
.flat_map(|entry| senpi_project_cwds_from_session_dir(&entry.path()))
.filter(|cwd| cwd.is_absolute())
.filter_map(|cwd| {
let children = senpi_omo_children_root(&cwd, user_state_dir);
children.is_dir().then_some(children)
})
.collect();
roots.sort_unstable();
roots.dedup();
roots
}
fn senpi_project_cwds_from_session_dir(session_dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(session_dir) else {
return Vec::new();
};
let mut cwds: Vec<PathBuf> = Vec::new();
for entry in entries.filter_map(|entry| entry.ok()) {
if !entry
.file_type()
.is_ok_and(|kind| kind.is_file() || (kind.is_symlink() && entry.path().is_file()))
{
continue;
}
let path = entry.path();
if !path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with(".jsonl"))
{
continue;
}
if let Some(cwd) = senpi_session_header_cwd(&path) {
if !cwds.contains(&cwd) {
cwds.push(cwd);
}
}
}
cwds.sort_unstable();
cwds
}
fn senpi_session_header_cwd(path: &Path) -> Option<PathBuf> {
use std::io::{BufRead, BufReader, Read};
let file = std::fs::File::open(path).ok()?;
let mut first_line = String::new();
BufReader::new(file.take(SENPI_SESSION_HEADER_MAX_BYTES))
.read_line(&mut first_line)
.ok()?;
let header: Value = serde_json::from_str(first_line.trim()).ok()?;
if header.get("type").and_then(Value::as_str) != Some("session") {
return None;
}
header.get("cwd").and_then(Value::as_str).map(PathBuf::from)
}
fn is_openclaw_compaction_checkpoint(file_name: &str) -> bool {
fn is_uuid(value: &str) -> bool {
if value.len() != 36 {
return false;
}
value.bytes().enumerate().all(|(index, byte)| match index {
8 | 13 | 18 | 23 => byte == b'-',
14 => matches!(byte, b'1'..=b'5'),
19 => matches!(byte.to_ascii_lowercase(), b'8' | b'9' | b'a' | b'b'),
_ => byte.is_ascii_hexdigit(),
})
}
let normalized = file_name.strip_suffix(".zst").unwrap_or(file_name);
let stem = normalized.strip_suffix(".jsonl").or_else(|| {
[".jsonl.deleted.", ".jsonl.reset."]
.into_iter()
.filter_map(|marker| normalized.rfind(marker))
.max()
.map(|index| &normalized[..index])
});
let Some(stem) = stem else {
return false;
};
let lowercase = stem.to_ascii_lowercase();
let Some((session_id, checkpoint_id)) = lowercase.rsplit_once(".checkpoint.") else {
return false;
};
!session_id.is_empty() && is_uuid(checkpoint_id)
}
pub fn scan_directory(root: &str, pattern: &str) -> Vec<PathBuf> {
if !std::path::Path::new(root).exists() {
return Vec::new();
}
let prune_extension_siblings = pattern == "codebuddy-extension-log";
let mut paths: Vec<PathBuf> = WalkDir::new(root)
.into_iter()
.filter_entry(|e| {
if !prune_extension_siblings || !e.file_type().is_dir() {
return true;
}
let under_exthost = e
.path()
.parent()
.and_then(Path::file_name)
.is_some_and(|parent| parent.eq_ignore_ascii_case("exthost"));
!under_exthost
|| e.file_name()
.eq_ignore_ascii_case("Tencent-Cloud.coding-copilot")
})
.filter_map(|e| e.ok())
.filter(|e| {
let path = e.path();
let file_type = e.file_type();
let is_file = file_type.is_file() || (file_type.is_symlink() && path.is_file());
if !is_file {
return false;
}
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
let is_in_archive_dir = path.components().any(|c| {
c.as_os_str()
.to_string_lossy()
.eq_ignore_ascii_case("archive")
});
match pattern {
"*.json" => file_name.ends_with(".json"),
"*.json|*.jsonl" => file_name.ends_with(".json") || file_name.ends_with(".jsonl"),
"*.jsonl" => file_name.ends_with(".jsonl"),
"*.ndjson" => file_name.ends_with(".ndjson"),
"*.log" => file_name.ends_with(".log"),
"codebuddy-extension-log" => {
file_name.ends_with(".log")
&& path.components().any(|component| {
component
.as_os_str()
.to_string_lossy()
.eq_ignore_ascii_case("Tencent-Cloud.coding-copilot")
})
}
"*.jsonl*" => {
!is_openclaw_compaction_checkpoint(file_name)
&& match file_name.split_once(".jsonl") {
Some((stem, suffix)) if !stem.is_empty() => {
suffix.is_empty()
|| (suffix.starts_with('.')
&& !suffix.ends_with(".json")
&& !suffix.ends_with(".json.migrated"))
}
_ => false,
}
}
"*.csv" => file_name.ends_with(".csv"),
"usage*.csv" => {
if is_in_archive_dir {
return false;
}
if file_name == "usage.csv" {
return true;
}
if !file_name.starts_with("usage.") || !file_name.ends_with(".csv") {
return false;
}
if file_name.starts_with("usage.backup") {
return false;
}
true
}
"usage*.json" => {
if is_in_archive_dir {
return false;
}
if file_name == "usage.json" {
return true;
}
if !file_name.starts_with("usage.") || !file_name.ends_with(".json") {
return false;
}
if file_name.starts_with("usage.backup") {
return false;
}
true
}
"session-*.json" => {
file_name.starts_with("session-") && file_name.ends_with(".json")
}
"session_*.json" => {
file_name.starts_with("session_") && file_name.ends_with(".json")
}
"T-*.json" => file_name.starts_with("T-") && file_name.ends_with(".json"),
"*.settings.json" => file_name.ends_with(".settings.json"),
"kiro-globalstorage" => {
file_name.ends_with(".chat")
|| file_name.ends_with(".json")
|| path.extension().is_none()
}
"kiro-ide-session" => {
file_name == "session.json"
&& path
.parent()
.and_then(|parent| parent.file_name())
.and_then(|name| name.to_str())
.map(|name| name.starts_with("sess_"))
.unwrap_or(false)
}
"sessions.json" => file_name == "sessions.json",
"wire.jsonl" => file_name == "wire.jsonl",
"updates.jsonl" => file_name == "updates.jsonl",
"events.jsonl" => file_name == "events.jsonl",
"ui_messages.json" => file_name == "ui_messages.json",
"cline-cli-messages" => file_name.ends_with(".messages.json"),
"session-usage.json" => file_name == "session-usage.json",
"chat-messages.json" => file_name == "chat-messages.json",
"usage-v2.json" => file_name == "usage-v2.json",
"dsh-session-log" => {
file_name == "session.jsonl.zstd" || file_name == "session.jsonl"
}
"prime-agent-session" => {
file_name.ends_with(".jsonl") && file_name != "rlm-subagents.jsonl"
}
"workbuddy.db" => file_name == "workbuddy.db",
"studio.db" => file_name == "studio.db",
"sessions.db" => file_name == "sessions.db",
"state.db" => file_name == "state.db",
"threads.db" => file_name == "threads.db",
"*.db" => file_name.ends_with(".db"),
_ => false,
}
})
.map(|e| e.path().to_path_buf())
.collect();
paths.sort_unstable();
paths
}
pub fn parse_extra_dirs(value: &str, enabled: &HashSet<ClientId>) -> Vec<(ClientId, String)> {
if value.is_empty() {
return Vec::new();
}
value
.split(',')
.filter_map(|entry| {
let entry = entry.trim();
let (client_str, path) = entry.split_once(':')?;
let client_id = ClientId::from_str(client_str.trim())?;
if !enabled.contains(&client_id) || !supports_extra_dir_scanning(client_id) {
return None;
}
let path = path.trim().to_string();
if path.is_empty() {
return None;
}
Some((client_id, path))
})
.collect()
}
pub fn extra_scan_paths_for(
settings: &ScannerSettings,
enabled: &HashSet<ClientId>,
) -> Vec<(ClientId, PathBuf)> {
settings
.extra_scan_paths
.iter()
.filter_map(|(client_str, paths)| {
let client_id = ClientId::from_str(client_str)?;
if !enabled.contains(&client_id) || !supports_extra_dir_scanning(client_id) {
return None;
}
Some(
paths
.iter()
.filter(|path| !path.as_os_str().is_empty())
.cloned()
.map(move |path| (client_id, path)),
)
})
.flatten()
.collect()
}
pub fn built_in_extra_scan_paths_for(
home_dir: &str,
enabled: &HashSet<ClientId>,
) -> Vec<(ClientId, PathBuf)> {
let mut paths = Vec::new();
if enabled.contains(&ClientId::Claude) {
paths.push((
ClientId::Claude,
PathBuf::from(format!("{}/.claude/transcripts", home_dir)),
));
paths.extend(
crate::cc_mirror::discover_claude_project_roots(Path::new(home_dir))
.into_iter()
.map(|path| (ClientId::Claude, path)),
);
}
paths
}
pub(crate) fn discover_hermes_profile_state_dbs(hermes_home: &Path) -> Vec<PathBuf> {
if hermes_home
.parent()
.and_then(Path::file_name)
.is_some_and(|name| name == "profiles")
{
return Vec::new();
}
let mut dbs: Vec<PathBuf> = std::fs::read_dir(hermes_home.join("profiles"))
.into_iter()
.flat_map(|entries| entries.filter_map(|entry| entry.ok()))
.filter_map(|entry| {
let state_db = entry.path().join("state.db");
state_db.is_file().then_some(state_db)
})
.collect();
dbs.sort_unstable();
dbs.dedup();
dbs
}
fn hermes_home_candidates(home_dir: &str, use_env_roots: bool) -> Vec<PathBuf> {
let mut homes = vec![PathBuf::from(
ClientId::Hermes
.data()
.root
.resolve_with_env_strategy(home_dir, use_env_roots),
)];
let hermes_home_set = use_env_roots
&& std::env::var("HERMES_HOME")
.map(|value| !value.trim().is_empty())
.unwrap_or(false);
if !hermes_home_set {
if cfg!(target_os = "windows") && use_env_roots {
if let Some(local_app_data) =
std::env::var_os("LOCALAPPDATA").filter(|value| !value.is_empty())
{
homes.push(PathBuf::from(local_app_data).join("hermes"));
}
}
homes.push(PathBuf::from(home_dir).join("AppData/Local/hermes"));
}
homes
}
#[derive(Debug, Deserialize, Default)]
struct CrushProjectList {
#[serde(default)]
projects: Vec<Value>,
}
#[derive(Debug, Deserialize)]
struct CrushProject {
path: String,
data_dir: String,
}
pub fn discover_openclaw_agent_dbs(agents_root: &Path) -> Vec<PathBuf> {
let entries = match std::fs::read_dir(agents_root) {
Ok(entries) => entries,
Err(_) => return Vec::new(),
};
let mut dbs: Vec<PathBuf> = entries
.filter_map(|entry| entry.ok())
.filter_map(|entry| {
let agent_dir = entry.path();
if !agent_dir.is_dir() {
return None;
}
let db_path = agent_dir
.join("agent")
.join(crate::sessions::openclaw::OPENCLAW_AGENT_DB_FILENAME);
db_path.is_file().then_some(db_path)
})
.collect();
dbs.sort_unstable();
dbs
}
pub(crate) fn discover_opencode_dbs(data_dir: &Path) -> Vec<PathBuf> {
let entries = match std::fs::read_dir(data_dir) {
Ok(entries) => entries,
Err(_) => return Vec::new(),
};
let mut dbs: Vec<PathBuf> = entries
.filter_map(|entry| entry.ok())
.filter_map(|entry| {
let file_type = entry.file_type().ok()?;
if !file_type.is_file() {
if !entry.path().is_file() {
return None;
}
}
let path = entry.path();
let name = path.file_name()?.to_str()?;
if !is_opencode_db_filename(name) {
return None;
}
Some(path)
})
.collect();
dbs.sort_unstable();
dbs
}
fn is_opencode_db_filename(name: &str) -> bool {
let stem = match name.strip_suffix(".db") {
Some(stem) => stem,
None => return false,
};
if stem == "opencode" {
return true;
}
let channel = match stem.strip_prefix("opencode-") {
Some(channel) => channel,
None => return false,
};
if channel.is_empty() {
return false;
}
channel
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
}
pub(crate) fn discover_micode_dbs(data_dir: &Path) -> Vec<PathBuf> {
let entries = match std::fs::read_dir(data_dir) {
Ok(entries) => entries,
Err(_) => return Vec::new(),
};
let mut dbs: Vec<PathBuf> = entries
.filter_map(|entry| entry.ok())
.filter_map(|entry| {
let file_type = entry.file_type().ok()?;
if !file_type.is_file() && !entry.path().is_file() {
return None;
}
let path = entry.path();
let name = path.file_name()?.to_str()?;
if !is_micode_db_filename(name) {
return None;
}
Some(path)
})
.collect();
dbs.sort_unstable();
dbs
}
fn dedup_dbs_by_canonical_path(paths: impl IntoIterator<Item = PathBuf>) -> Vec<PathBuf> {
let mut seen = HashSet::new();
let mut dbs = Vec::new();
for db_path in paths {
let key = std::fs::canonicalize(&db_path).unwrap_or_else(|_| db_path.clone());
if seen.insert(key) {
dbs.push(db_path);
}
}
dbs.sort_unstable();
dbs
}
pub(crate) fn discover_micode_dbs_in_dirs(dirs: impl IntoIterator<Item = PathBuf>) -> Vec<PathBuf> {
dedup_dbs_by_canonical_path(dirs.into_iter().flat_map(|dir| discover_micode_dbs(&dir)))
}
fn discover_devin_cli_dbs(roots: impl IntoIterator<Item = PathBuf>) -> Vec<PathBuf> {
dedup_dbs_by_canonical_path(
roots
.into_iter()
.flat_map(|root| scan_directory(&root.to_string_lossy(), "sessions.db")),
)
}
fn is_micode_db_filename(name: &str) -> bool {
let stem = match name.strip_suffix(".db") {
Some(stem) => stem,
None => return false,
};
if stem == "mimocode" {
return true;
}
let channel = match stem.strip_prefix("mimocode-") {
Some(channel) => channel,
None => return false,
};
if channel.is_empty() {
return false;
}
channel
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
}
fn crush_db_path(data_dir: &Path) -> Option<PathBuf> {
let candidate = data_dir.join("crush.db");
candidate.is_file().then_some(candidate)
}
fn resolve_crush_data_dir(project: &CrushProject) -> PathBuf {
let data_dir = PathBuf::from(&project.data_dir);
if data_dir.is_absolute() {
data_dir
} else {
PathBuf::from(&project.path).join(data_dir)
}
}
fn scan_crush_registry(registry_path: &Path) -> Vec<CrushDbSource> {
let registry = match std::fs::read_to_string(registry_path) {
Ok(contents) => contents,
Err(_) => return Vec::new(),
};
let list: CrushProjectList = match serde_json::from_str(®istry) {
Ok(list) => list,
Err(_) => return Vec::new(),
};
list.projects
.into_iter()
.filter_map(|project| serde_json::from_value::<CrushProject>(project).ok())
.filter_map(|project| {
let db_path = crush_db_path(&resolve_crush_data_dir(&project))?;
let workspace_key = normalize_workspace_key(&project.path);
let workspace_label = workspace_key.as_deref().and_then(workspace_label_from_key);
Some(CrushDbSource {
db_path,
workspace_key,
workspace_label,
})
})
.collect()
}
fn crush_registry_candidates(home_dir: &str, use_env_roots: bool) -> Vec<PathBuf> {
let mut candidates = Vec::new();
if use_env_roots {
if let Some(global_data) =
std::env::var_os("CRUSH_GLOBAL_DATA").filter(|value| !value.is_empty())
{
candidates.push(PathBuf::from(global_data).join("projects.json"));
}
}
candidates.push(PathBuf::from(
ClientId::Crush
.data()
.resolve_path_with_env_strategy(home_dir, use_env_roots),
));
if cfg!(target_os = "windows") && use_env_roots {
if let Some(local_app_data) =
std::env::var_os("LOCALAPPDATA").filter(|value| !value.is_empty())
{
candidates.push(
PathBuf::from(local_app_data)
.join("crush")
.join("projects.json"),
);
}
}
candidates.push(PathBuf::from(home_dir).join("AppData/Local/crush/projects.json"));
candidates
}
fn discover_crush_dbs(home_dir: &str, use_env_roots: bool) -> Vec<CrushDbSource> {
let mut dbs = Vec::new();
for registry_path in crush_registry_candidates(home_dir, use_env_roots) {
dbs.extend(scan_crush_registry(®istry_path));
}
dbs.sort_by(|a, b| a.db_path.cmp(&b.db_path));
dbs.dedup_by(|a, b| a.db_path == b.db_path);
dbs
}
fn cline_cli_session_roots(home_dir: &str, use_env_roots: bool) -> Vec<PathBuf> {
let home_fallback = || PathBuf::from(home_dir).join(".cline/data/sessions");
if !use_env_roots {
return vec![home_fallback()];
}
let non_blank_env_path = |name: &str| {
std::env::var_os(name)
.filter(|value| !value.to_string_lossy().trim().is_empty())
.map(PathBuf::from)
};
if let Some(path) = non_blank_env_path("CLINE_SESSION_DATA_DIR") {
return vec![path];
}
if let Some(path) = non_blank_env_path("CLINE_DATA_DIR") {
return vec![path.join("sessions")];
}
if let Some(path) = non_blank_env_path("CLINE_DIR") {
return vec![path.join("data/sessions")];
}
vec![home_fallback()]
}
fn kimi_work_share_dir_root(app_data: &Path) -> Option<PathBuf> {
let config_path = app_data.join("kimi-desktop").join("daimon-storage.json");
let content = std::fs::read_to_string(config_path).ok()?;
let config: Value = serde_json::from_str(&content).ok()?;
let share_dir = config.get("shareDir")?.as_str()?;
if share_dir.trim().is_empty() {
return None;
}
Some(Path::new(share_dir).join("daimon/runtime/kimi-code/home/sessions"))
}
fn kimi_work_roots(home_dir: &str, use_env_roots: bool) -> Vec<PathBuf> {
const KIMI_WORK_SUFFIX: &str =
"kimi-desktop/daimon-share/daimon/runtime/kimi-code/home/sessions";
if cfg!(target_os = "macos") {
return vec![PathBuf::from(home_dir)
.join("Library/Application Support")
.join(KIMI_WORK_SUFFIX)];
}
if cfg!(target_os = "windows") {
let mut roots = vec![PathBuf::from(home_dir)
.join("AppData/Roaming")
.join(KIMI_WORK_SUFFIX)];
if use_env_roots {
if let Some(app_data) = std::env::var_os("APPDATA").filter(|value| !value.is_empty()) {
let app_data = PathBuf::from(app_data);
roots.push(
kimi_work_share_dir_root(&app_data)
.unwrap_or_else(|| app_data.join(KIMI_WORK_SUFFIX)),
);
}
}
return roots;
}
Vec::new()
}
fn expand_tilde_path_with_home(path: &str, home_dir: &str) -> PathBuf {
if path == "~" {
return PathBuf::from(home_dir);
}
if let Some(relative) = path.strip_prefix("~/").or_else(|| path.strip_prefix("~\\")) {
return PathBuf::from(home_dir).join(relative);
}
PathBuf::from(path)
}
pub fn prime_agent_session_roots_with_env_strategy(
home_dir: &str,
use_env_roots: bool,
) -> [PathBuf; 2] {
fn sessions_with_sibling_artifacts(sessions: PathBuf) -> [PathBuf; 2] {
let artifacts = sessions
.parent()
.unwrap_or_else(|| Path::new(""))
.join("session-artifacts");
[sessions, artifacts]
}
if !use_env_roots {
let agent_dir = PathBuf::from(home_dir).join(".prime/agent");
return [
agent_dir.join("sessions"),
agent_dir.join("session-artifacts"),
];
}
let session_override = std::env::var("PRIME_AGENT_SESSION_DIR")
.ok()
.or_else(|| std::env::var("PRIME_AGENT_CODING_AGENT_SESSION_DIR").ok());
if let Some(path) = session_override.filter(|value| !value.is_empty()) {
return sessions_with_sibling_artifacts(expand_tilde_path_with_home(&path, home_dir));
}
let agent_dir = std::env::var("PRIME_AGENT_CODING_AGENT_DIR")
.ok()
.filter(|value| !value.is_empty())
.map(|path| expand_tilde_path_with_home(&path, home_dir))
.unwrap_or_else(|| PathBuf::from(home_dir).join(".prime/agent"));
[
agent_dir.join("sessions"),
agent_dir.join("session-artifacts"),
]
}
fn cline_additional_vscode_task_roots(home_dir: &str, use_env_roots: bool) -> Vec<PathBuf> {
let mut roots = vec![PathBuf::from(home_dir)
.join("Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/tasks")];
if cfg!(target_os = "windows") && use_env_roots {
if let Some(app_data) = std::env::var_os("APPDATA").filter(|value| !value.is_empty()) {
roots.push(
PathBuf::from(app_data)
.join("Code/User/globalStorage/saoudrizwan.claude-dev/tasks"),
);
}
}
roots.push(
PathBuf::from(home_dir)
.join("AppData/Roaming/Code/User/globalStorage/saoudrizwan.claude-dev/tasks"),
);
roots.push(
PathBuf::from(home_dir)
.join(".vscode-server/data/User/globalStorage/saoudrizwan.claude-dev/tasks"),
);
roots
}
pub fn devin_desktop_additional_roots(home_dir: &str, use_env_roots: bool) -> Vec<PathBuf> {
let mut roots = vec![
PathBuf::from(home_dir).join(".config/Devin/User/acp-events"),
PathBuf::from(home_dir).join(".config/devin/User/acp-events"),
];
if cfg!(target_os = "windows") && use_env_roots {
if let Some(app_data) = std::env::var_os("APPDATA").filter(|value| !value.is_empty()) {
roots.push(PathBuf::from(app_data).join("Devin/User/acp-events"));
}
}
roots.push(PathBuf::from(home_dir).join("AppData/Roaming/Devin/User/acp-events"));
roots
}
fn supports_extra_dir_scanning(client_id: ClientId) -> bool {
!matches!(
client_id,
ClientId::Kilo | ClientId::Crush | ClientId::Goose
)
}
fn push_unique_scan_task(
tasks: &mut Vec<(ClientId, String, &'static str)>,
seen: &mut HashSet<(ClientId, PathBuf)>,
client_id: ClientId,
raw_path: impl Into<PathBuf>,
) {
push_unique_scan_task_with_pattern(tasks, seen, client_id, raw_path, client_id.data().pattern);
}
fn push_unique_scan_task_with_pattern(
tasks: &mut Vec<(ClientId, String, &'static str)>,
seen: &mut HashSet<(ClientId, PathBuf)>,
client_id: ClientId,
raw_path: impl Into<PathBuf>,
pattern: &'static str,
) {
let raw_path = raw_path.into();
if raw_path.as_os_str().is_empty() {
return;
}
let key = std::fs::canonicalize(&raw_path).unwrap_or_else(|_| raw_path.clone());
if seen.insert((client_id, key)) {
tasks.push((client_id, raw_path.to_string_lossy().to_string(), pattern));
}
}
fn kiro_global_storage_roots(home_dir: &str, use_env_roots: bool) -> Vec<PathBuf> {
let mut roots = vec![
PathBuf::from(format!(
"{}/Library/Application Support/Kiro/User/globalStorage/kiro.kiroagent",
home_dir
)),
PathBuf::from(format!(
"{}/Library/Application Support/kiro/User/globalStorage/kiro.kiroagent",
home_dir
)),
PathBuf::from(format!(
"{}/.config/Kiro/User/globalStorage/kiro.kiroagent",
home_dir
)),
PathBuf::from(format!(
"{}/.config/kiro/User/globalStorage/kiro.kiroagent",
home_dir
)),
];
if cfg!(target_os = "windows") {
if use_env_roots {
if let Some(app_data) = std::env::var_os("APPDATA").filter(|value| !value.is_empty()) {
roots.push(PathBuf::from(&app_data).join("Kiro/User/globalStorage/kiro.kiroagent"));
roots.push(PathBuf::from(&app_data).join("kiro/User/globalStorage/kiro.kiroagent"));
}
}
roots.push(PathBuf::from(format!(
"{}/AppData/Roaming/Kiro/User/globalStorage/kiro.kiroagent",
home_dir
)));
roots.push(PathBuf::from(format!(
"{}/AppData/Roaming/kiro/User/globalStorage/kiro.kiroagent",
home_dir
)));
}
roots
}
pub(crate) fn merge_user_opencode_db_paths(discovered: &mut Vec<PathBuf>, extra_paths: &[PathBuf]) {
if extra_paths.is_empty() {
return;
}
let mut seen: HashSet<PathBuf> = discovered
.iter()
.map(|p| std::fs::canonicalize(p).unwrap_or_else(|_| p.clone()))
.collect();
for raw in extra_paths {
if !raw.is_file() {
continue;
}
let Some(name) = raw.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !is_opencode_db_filename(name) {
continue;
}
let canonical = std::fs::canonicalize(raw).unwrap_or_else(|_| raw.clone());
if seen.insert(canonical) {
discovered.push(raw.clone());
}
}
}
fn discover_copilot_vscode_sessions(home_dir: &str, use_env_roots: bool) -> Vec<PathBuf> {
let mut roots: Vec<PathBuf> = Vec::new();
roots.push(PathBuf::from(format!(
"{}/Library/Application Support/Code/User/workspaceStorage",
home_dir
)));
roots.push(PathBuf::from(format!(
"{}/.config/Code/User/workspaceStorage",
home_dir
)));
if cfg!(target_os = "windows") && use_env_roots {
if let Some(app_data) = std::env::var_os("APPDATA").filter(|v| !v.is_empty()) {
roots.push(PathBuf::from(app_data).join("Code/User/workspaceStorage"));
}
}
roots.push(PathBuf::from(home_dir).join("AppData/Roaming/Code/User/workspaceStorage"));
let mut files: Vec<PathBuf> = Vec::new();
let mut seen: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
for workspace_storage in &roots {
let hash_dirs = match std::fs::read_dir(workspace_storage) {
Ok(rd) => rd,
Err(_) => continue,
};
for entry in hash_dirs.filter_map(|e| e.ok()) {
let chat_sessions_dir = entry.path().join("chatSessions");
if !chat_sessions_dir.is_dir() {
continue;
}
let chat_entries = match std::fs::read_dir(&chat_sessions_dir) {
Ok(rd) => rd,
Err(_) => continue,
};
for chat_entry in chat_entries.filter_map(|e| e.ok()) {
let path = chat_entry.path();
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if !name.ends_with(".jsonl") {
continue;
}
if !path.is_file() {
continue;
}
let key = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone());
if seen.insert(key) {
files.push(path);
}
}
}
}
files.sort_unstable();
files
}
pub fn scan_all_clients_with_scanner_settings(
home_dir: &str,
clients: &[String],
use_env_roots: bool,
scanner_settings: &ScannerSettings,
) -> ScanResult {
scan_all_clients_with_env_strategy_inner(home_dir, clients, use_env_roots, scanner_settings)
}
pub fn scan_all_clients_with_env_strategy(
home_dir: &str,
clients: &[String],
use_env_roots: bool,
) -> ScanResult {
scan_all_clients_with_scanner_settings(
home_dir,
clients,
use_env_roots,
&ScannerSettings::default(),
)
}
fn scan_all_clients_with_env_strategy_inner(
home_dir: &str,
clients: &[String],
use_env_roots: bool,
scanner_settings: &ScannerSettings,
) -> ScanResult {
let mut result = ScanResult::default();
let include_all = clients.is_empty();
let include_synthetic = include_all || clients.iter().any(|s| s == "synthetic");
let enabled: HashSet<ClientId> = if include_all || include_synthetic {
ClientId::iter().collect()
} else {
clients
.iter()
.filter_map(|s| {
ClientId::from_str(s).or_else(|| {
if s.eq_ignore_ascii_case("9router") {
Some(ClientId::Gjc)
} else {
None
}
})
})
.collect()
};
let mut enabled_with_lookups = enabled.clone();
if enabled.contains(&ClientId::DevinDesktop) {
enabled_with_lookups.insert(ClientId::DevinCli);
}
if enabled.contains(&ClientId::OpenClaw) {
enabled_with_lookups.insert(ClientId::Codex);
}
let headless_roots = headless_roots_with_env_strategy(home_dir, use_env_roots);
const SCAN_WORKER_CEILING: usize = 4;
let mut tasks: Vec<(ClientId, String, &str)> = Vec::new();
let mut seen_scan_roots: HashSet<(ClientId, PathBuf)> = HashSet::new();
let mut devin_cli_roots: Vec<PathBuf> = Vec::new();
let mut openclaw_agent_roots: Vec<PathBuf> = Vec::new();
for client_id in &enabled {
if matches!(
client_id,
ClientId::OpenCode
| ClientId::Codex
| ClientId::OpenClaw
| ClientId::RooCode
| ClientId::KiloCode
| ClientId::Cline
| ClientId::Kilo
| ClientId::Hermes
| ClientId::Goose
| ClientId::Zed
| ClientId::Crush
| ClientId::Codebuff
| ClientId::Kimi
| ClientId::Gjc
| ClientId::MiMoCode
| ClientId::DevinCli
| ClientId::Mcode
| ClientId::PrimeAgent
| ClientId::Freebuff
| ClientId::Pi
) {
continue;
}
let def = client_id.data();
let path = def.resolve_path_with_env_strategy(home_dir, use_env_roots);
push_unique_scan_task(&mut tasks, &mut seen_scan_roots, *client_id, path);
}
for (client_id, path) in extra_scan_paths_for(scanner_settings, &enabled_with_lookups) {
warn_if_escapes_home(Path::new(home_dir), client_id, &path);
if client_id == ClientId::OpenClaw {
openclaw_agent_roots.push(path.clone());
}
if client_id == ClientId::DevinCli {
devin_cli_roots.push(path);
} else {
push_unique_scan_task(&mut tasks, &mut seen_scan_roots, client_id, path);
}
}
for (client_id, path) in built_in_extra_scan_paths_for(home_dir, &enabled) {
push_unique_scan_task(&mut tasks, &mut seen_scan_roots, client_id, path);
}
if enabled.contains(&ClientId::CodeBuddy) {
let home_path = PathBuf::from(home_dir);
let mut codebuddy_log_roots = vec![(
home_path
.join("AppData")
.join("Local")
.join("CodeBuddyExtension")
.join("Logs"),
"*.log",
)];
let roaming_codebuddy_roots = [
home_path
.join("AppData")
.join("Roaming")
.join("CodeBuddy CN")
.join("logs"),
home_path
.join("AppData")
.join("Roaming")
.join("Code")
.join("logs"),
];
codebuddy_log_roots.extend(
roaming_codebuddy_roots
.into_iter()
.map(|root| (root, "codebuddy-extension-log")),
);
if use_env_roots {
if let Some(local_app_data) = dirs::data_local_dir() {
codebuddy_log_roots.push((
local_app_data.join("CodeBuddyExtension").join("Logs"),
"*.log",
));
}
if let Some(roaming_app_data) = dirs::config_dir() {
codebuddy_log_roots.push((
roaming_app_data.join("CodeBuddy CN").join("logs"),
"codebuddy-extension-log",
));
codebuddy_log_roots.push((
roaming_app_data.join("Code").join("logs"),
"codebuddy-extension-log",
));
}
}
for (log_root, pattern) in codebuddy_log_roots {
if pattern == "*.log" {
for root in ["CodeBuddyIDE", "VSCode"] {
push_unique_scan_task_with_pattern(
&mut tasks,
&mut seen_scan_roots,
ClientId::CodeBuddy,
log_root.join(root),
pattern,
);
}
continue;
}
push_unique_scan_task_with_pattern(
&mut tasks,
&mut seen_scan_roots,
ClientId::CodeBuddy,
log_root,
pattern,
);
}
}
if enabled.contains(&ClientId::WorkBuddy) {
push_unique_scan_task_with_pattern(
&mut tasks,
&mut seen_scan_roots,
ClientId::WorkBuddy,
PathBuf::from(home_dir).join(".workbuddy/projects"),
"*.jsonl",
);
}
if use_env_roots {
let extra_dirs_val = std::env::var("TOKMESH_EXTRA_DIRS").unwrap_or_default();
for (client_id, path) in parse_extra_dirs(&extra_dirs_val, &enabled_with_lookups) {
warn_if_escapes_home(Path::new(home_dir), client_id, &PathBuf::from(&path));
if client_id == ClientId::OpenClaw {
openclaw_agent_roots.push(PathBuf::from(&path));
}
if client_id == ClientId::DevinCli {
devin_cli_roots.push(PathBuf::from(path));
} else {
push_unique_scan_task(&mut tasks, &mut seen_scan_roots, client_id, path);
}
}
}
if enabled.contains(&ClientId::OpenCode) {
let xdg_data = if use_env_roots {
std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| format!("{}/.local/share", home_dir))
} else {
format!("{}/.local/share", home_dir)
};
let opencode_data_dir = PathBuf::from(format!("{}/opencode", xdg_data));
result.opencode_dbs = discover_opencode_dbs(&opencode_data_dir);
merge_user_opencode_db_paths(
&mut result.opencode_dbs,
&scanner_settings.opencode_db_paths,
);
result.opencode_dbs.sort_unstable();
result.opencode_dbs.dedup();
let opencode_path = ClientId::OpenCode
.data()
.resolve_path_with_env_strategy(home_dir, use_env_roots);
result.opencode_json_dir = Some(PathBuf::from(&opencode_path));
push_unique_scan_task(
&mut tasks,
&mut seen_scan_roots,
ClientId::OpenCode,
opencode_path,
);
}
if enabled.contains(&ClientId::MiMoCode) {
let micode_data_dir = PathBuf::from(
ClientId::MiMoCode
.data()
.resolve_path_with_env_strategy(home_dir, use_env_roots),
);
let orca_data_dir = PathBuf::from(format!(
"{}/Library/Application Support/orca/mimocode-hooks/shared/data",
home_dir
));
result.micode_dbs = discover_micode_dbs_in_dirs([micode_data_dir, orca_data_dir]);
}
if enabled.contains(&ClientId::Kimi) {
let kimi_path = ClientId::Kimi
.data()
.resolve_path_with_env_strategy(home_dir, use_env_roots);
push_unique_scan_task(&mut tasks, &mut seen_scan_roots, ClientId::Kimi, kimi_path);
let kimi_code_home = if use_env_roots {
let configured = std::env::var("KIMI_CODE_HOME").unwrap_or_default();
if configured.trim().is_empty() {
format!("{}/.kimi-code", home_dir)
} else {
configured
}
} else {
format!("{}/.kimi-code", home_dir)
};
let kimi_code_path = format!("{}/sessions", kimi_code_home);
push_unique_scan_task(
&mut tasks,
&mut seen_scan_roots,
ClientId::Kimi,
kimi_code_path,
);
for work_root in kimi_work_roots(home_dir, use_env_roots) {
push_unique_scan_task(&mut tasks, &mut seen_scan_roots, ClientId::Kimi, work_root);
}
}
if enabled_with_lookups.contains(&ClientId::Codex) {
let codex_home = if use_env_roots {
std::env::var("CODEX_HOME").unwrap_or_else(|_| format!("{}/.codex", home_dir))
} else {
format!("{}/.codex", home_dir)
};
let codex_path = ClientId::Codex
.data()
.resolve_path_with_env_strategy(home_dir, use_env_roots);
push_unique_scan_task(
&mut tasks,
&mut seen_scan_roots,
ClientId::Codex,
codex_path,
);
let codex_archived_path = format!("{}/archived_sessions", codex_home);
push_unique_scan_task(
&mut tasks,
&mut seen_scan_roots,
ClientId::Codex,
codex_archived_path,
);
for root in &headless_roots {
push_unique_scan_task(
&mut tasks,
&mut seen_scan_roots,
ClientId::Codex,
root.join("codex"),
);
}
}
if enabled.contains(&ClientId::OpenClaw) {
let openclaw_path = ClientId::OpenClaw
.data()
.resolve_path_with_env_strategy(home_dir, use_env_roots);
openclaw_agent_roots.push(PathBuf::from(&openclaw_path));
push_unique_scan_task(
&mut tasks,
&mut seen_scan_roots,
ClientId::OpenClaw,
openclaw_path,
);
let clawdbot_path = format!("{}/.clawdbot/agents", home_dir);
for relative in [".clawdbot/agents", ".moltbot/agents", ".moldbot/agents"] {
openclaw_agent_roots.push(Path::new(home_dir).join(relative));
}
push_unique_scan_task(
&mut tasks,
&mut seen_scan_roots,
ClientId::OpenClaw,
clawdbot_path,
);
let moltbot_path = format!("{}/.moltbot/agents", home_dir);
push_unique_scan_task(
&mut tasks,
&mut seen_scan_roots,
ClientId::OpenClaw,
moltbot_path,
);
let moldbot_path = format!("{}/.moldbot/agents", home_dir);
push_unique_scan_task(
&mut tasks,
&mut seen_scan_roots,
ClientId::OpenClaw,
moldbot_path,
);
}
if enabled.contains(&ClientId::Pi) {
let pi_path = ClientId::Pi
.data()
.resolve_path_with_env_strategy(home_dir, use_env_roots);
push_unique_scan_task(&mut tasks, &mut seen_scan_roots, ClientId::Pi, pi_path);
}
if include_synthetic {
let xdg_data = if use_env_roots {
std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| format!("{}/.local/share", home_dir))
} else {
format!("{}/.local/share", home_dir)
};
let octofriend_db_path = PathBuf::from(format!("{}/octofriend/sqlite.db", xdg_data));
if octofriend_db_path.exists() {
result.synthetic_db = Some(octofriend_db_path);
}
}
if enabled.contains(&ClientId::RooCode) {
let local_path = ClientId::RooCode
.data()
.resolve_path_with_env_strategy(home_dir, use_env_roots);
push_unique_scan_task(
&mut tasks,
&mut seen_scan_roots,
ClientId::RooCode,
local_path,
);
let server_path = format!(
"{}/.vscode-server/data/User/globalStorage/rooveterinaryinc.roo-cline/tasks",
home_dir
);
push_unique_scan_task(
&mut tasks,
&mut seen_scan_roots,
ClientId::RooCode,
server_path,
);
}
if enabled.contains(&ClientId::KiloCode) {
let local_path = ClientId::KiloCode
.data()
.resolve_path_with_env_strategy(home_dir, use_env_roots);
push_unique_scan_task(
&mut tasks,
&mut seen_scan_roots,
ClientId::KiloCode,
local_path,
);
let server_path = format!(
"{}/.vscode-server/data/User/globalStorage/kilocode.kilo-code/tasks",
home_dir
);
push_unique_scan_task(
&mut tasks,
&mut seen_scan_roots,
ClientId::KiloCode,
server_path,
);
}
if enabled.contains(&ClientId::Cline) {
let local_path = ClientId::Cline
.data()
.resolve_path_with_env_strategy(home_dir, use_env_roots);
push_unique_scan_task(
&mut tasks,
&mut seen_scan_roots,
ClientId::Cline,
local_path,
);
for root in cline_additional_vscode_task_roots(home_dir, use_env_roots) {
push_unique_scan_task(&mut tasks, &mut seen_scan_roots, ClientId::Cline, root);
}
for root in cline_cli_session_roots(home_dir, use_env_roots) {
push_unique_scan_task_with_pattern(
&mut tasks,
&mut seen_scan_roots,
ClientId::Cline,
root,
"cline-cli-messages",
);
}
}
if enabled.contains(&ClientId::DevinDesktop) {
let local_path = ClientId::DevinDesktop
.data()
.resolve_path_with_env_strategy(home_dir, use_env_roots);
push_unique_scan_task(
&mut tasks,
&mut seen_scan_roots,
ClientId::DevinDesktop,
local_path,
);
for root in devin_desktop_additional_roots(home_dir, use_env_roots) {
push_unique_scan_task(
&mut tasks,
&mut seen_scan_roots,
ClientId::DevinDesktop,
root,
);
}
}
if enabled.contains(&ClientId::Kilo) {
let kilo_db_path = ClientId::Kilo
.data()
.resolve_path_with_env_strategy(home_dir, use_env_roots);
if std::path::Path::new(&kilo_db_path).exists() {
result.kilo_db = Some(PathBuf::from(kilo_db_path));
}
}
if enabled.contains(&ClientId::DevinCli) || enabled.contains(&ClientId::DevinDesktop) {
devin_cli_roots.extend(devin_cli_additional_roots(home_dir, use_env_roots));
let devin_db_path = ClientId::DevinCli
.data()
.resolve_path_with_env_strategy(home_dir, use_env_roots);
devin_cli_roots.push(PathBuf::from(devin_db_path));
result.devin_dbs = discover_devin_cli_dbs(devin_cli_roots);
}
if enabled.contains(&ClientId::Hermes) {
let mut extra_dbs: Vec<PathBuf> = Vec::new();
for hermes_home in hermes_home_candidates(home_dir, use_env_roots) {
let default_db = hermes_home.join("state.db");
if default_db.is_file() {
if result.hermes_db.is_none() {
result.hermes_db = Some(default_db);
} else if result.hermes_db.as_ref() != Some(&default_db) {
extra_dbs.push(default_db);
}
}
extra_dbs.extend(discover_hermes_profile_state_dbs(&hermes_home));
}
extra_dbs.sort_unstable();
extra_dbs.dedup();
result.get_mut(ClientId::Hermes).extend(extra_dbs);
}
if enabled.contains(&ClientId::Goose) {
if use_env_roots {
if let Ok(custom_root) = std::env::var("GOOSE_PATH_ROOT") {
let trimmed = custom_root.trim();
if !trimmed.is_empty() {
let custom_path = PathBuf::from(trimmed).join("data/sessions/sessions.db");
if custom_path.is_file() {
result.goose_db = Some(custom_path);
}
}
}
}
if result.goose_db.is_none() {
let xdg_path = ClientId::Goose
.data()
.resolve_path_with_env_strategy(home_dir, use_env_roots);
let xdg = PathBuf::from(xdg_path);
if xdg.is_file() {
result.goose_db = Some(xdg);
}
}
if result.goose_db.is_none() {
let macos_path = PathBuf::from(format!(
"{}/Library/Application Support/goose/sessions/sessions.db",
home_dir
));
if macos_path.is_file() {
result.goose_db = Some(macos_path);
}
}
if result.goose_db.is_none() {
let legacy_macos_path = PathBuf::from(format!(
"{}/Library/Application Support/Block/goose/sessions/sessions.db",
home_dir
));
if legacy_macos_path.is_file() {
result.goose_db = Some(legacy_macos_path);
}
}
if result.goose_db.is_none() {
let legacy_xdg_path = PathBuf::from(format!(
"{}/.local/share/Block/goose/sessions/sessions.db",
home_dir
));
if legacy_xdg_path.is_file() {
result.goose_db = Some(legacy_xdg_path);
}
}
}
if enabled.contains(&ClientId::Zed) {
let zed_db_path = ClientId::Zed
.data()
.resolve_path_with_env_strategy(home_dir, use_env_roots);
let xdg = PathBuf::from(zed_db_path);
if xdg.is_file() {
result.zed_db = Some(xdg);
}
#[cfg(target_os = "macos")]
if result.zed_db.is_none() {
let macos_path = PathBuf::from(format!(
"{}/Library/Application Support/Zed/threads/threads.db",
home_dir
));
if macos_path.is_file() {
result.zed_db = Some(macos_path);
}
}
if !use_env_roots && result.zed_db.is_none() {
let windows_path = PathBuf::from(home_dir).join("AppData/Local/Zed/threads/threads.db");
if windows_path.is_file() {
result.zed_db = Some(windows_path);
}
}
#[cfg(target_os = "windows")]
if use_env_roots && result.zed_db.is_none() {
if let Some(local_app_data) = dirs::data_local_dir() {
let windows_path = local_app_data.join("Zed/threads/threads.db");
if windows_path.is_file() {
result.zed_db = Some(windows_path);
}
}
}
}
if enabled.contains(&ClientId::Crush) {
result.crush_dbs = discover_crush_dbs(home_dir, use_env_roots);
}
if enabled.contains(&ClientId::Zcode) {
let zcode_db_path = PathBuf::from(format!("{}/.zcode/cli/db/db.sqlite", home_dir));
if zcode_db_path.is_file() {
result.zcode_db = Some(zcode_db_path);
}
}
if enabled.contains(&ClientId::Kiro) {
let kiro_cli_path = ClientId::Kiro
.data()
.resolve_path_with_env_strategy(home_dir, use_env_roots);
push_unique_scan_task_with_pattern(
&mut tasks,
&mut seen_scan_roots,
ClientId::Kiro,
kiro_cli_path,
"*.json",
);
for root in kiro_global_storage_roots(home_dir, use_env_roots) {
push_unique_scan_task_with_pattern(
&mut tasks,
&mut seen_scan_roots,
ClientId::Kiro,
root,
"kiro-globalstorage",
);
}
let kiro_ide_sessions_root = PathBuf::from(format!("{}/.kiro/sessions", home_dir));
push_unique_scan_task_with_pattern(
&mut tasks,
&mut seen_scan_roots,
ClientId::Kiro,
kiro_ide_sessions_root,
"kiro-ide-session",
);
let xdg_path = PathBuf::from(format!("{}/.local/share/kiro-cli/data.sqlite3", home_dir));
if xdg_path.is_file() {
result.kiro_db = Some(xdg_path);
}
if result.kiro_db.is_none() {
let macos_path = PathBuf::from(format!(
"{}/Library/Application Support/kiro-cli/data.sqlite3",
home_dir
));
if macos_path.is_file() {
result.kiro_db = Some(macos_path);
}
}
}
if enabled.contains(&ClientId::Codebuff) {
let trimmed_override = if use_env_roots {
std::env::var("CODEBUFF_DATA_DIR")
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
} else {
None
};
let mut codebuff_roots: Vec<String> = Vec::new();
if let Some(root) = trimmed_override {
codebuff_roots.push(format!("{}/projects", root.trim_end_matches('/')));
} else {
let config_dir = format!("{}/.config", home_dir);
for channel in ["manicode", "manicode-dev", "manicode-staging"] {
codebuff_roots.push(format!("{}/{}/projects", config_dir, channel));
}
}
for root in codebuff_roots {
push_unique_scan_task(&mut tasks, &mut seen_scan_roots, ClientId::Codebuff, root);
}
}
if enabled.contains(&ClientId::Freebuff) {
let trimmed_override = if use_env_roots {
std::env::var("FREEBUFF_DATA_DIR")
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
.or_else(|| {
std::env::var("CODEBUFF_DATA_DIR")
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
})
} else {
None
};
let mut freebuff_roots: Vec<String> = Vec::new();
if let Some(root) = trimmed_override {
freebuff_roots.push(format!("{}/projects", root.trim_end_matches('/')));
} else {
let config_dir = format!("{}/.config", home_dir);
for channel in ["manicode", "manicode-dev", "manicode-staging"] {
freebuff_roots.push(format!("{}/{}/projects", config_dir, channel));
}
}
for root in freebuff_roots {
push_unique_scan_task(&mut tasks, &mut seen_scan_roots, ClientId::Freebuff, root);
}
}
if enabled.contains(&ClientId::Mcode) {
for root in &headless_roots {
push_unique_scan_task(
&mut tasks,
&mut seen_scan_roots,
ClientId::Mcode,
root.join("mcode"),
);
}
}
if enabled.contains(&ClientId::PrimeAgent) {
let [sessions, artifacts] =
prime_agent_session_roots_with_env_strategy(home_dir, use_env_roots);
push_unique_scan_task_with_pattern(
&mut tasks,
&mut seen_scan_roots,
ClientId::PrimeAgent,
sessions,
"prime-agent-session",
);
push_unique_scan_task_with_pattern(
&mut tasks,
&mut seen_scan_roots,
ClientId::PrimeAgent,
artifacts,
"prime-agent-session",
);
}
if enabled.contains(&ClientId::CherryStudio) {
let cherry_projects = ClientId::CherryStudio
.data()
.resolve_path_with_env_strategy(home_dir, use_env_roots);
push_unique_scan_task_with_pattern(
&mut tasks,
&mut seen_scan_roots,
ClientId::CherryStudio,
cherry_projects.clone(),
"*.jsonl",
);
if let Some(base) = Path::new(&cherry_projects).parent().and_then(Path::parent) {
let cherry_v2 = base
.join("Data")
.join("Agents")
.join(".claude")
.join("projects");
push_unique_scan_task_with_pattern(
&mut tasks,
&mut seen_scan_roots,
ClientId::CherryStudio,
cherry_v2,
"*.jsonl",
);
}
}
if enabled.contains(&ClientId::Senpi) {
let senpi_path = ClientId::Senpi
.data()
.resolve_path_with_env_strategy(home_dir, use_env_roots);
push_unique_scan_task(
&mut tasks,
&mut seen_scan_roots,
ClientId::Senpi,
senpi_path.clone(),
);
let user_state_dir = match omo_task_state(&Path::new(home_dir).join(".omo")) {
OmoTaskState::StateDir(path) => Some(path),
OmoTaskState::DefaultLayout | OmoTaskState::Unset => None,
};
if use_env_roots {
let env_session_dir =
std::env::var_os("SENPI_CODING_AGENT_SESSION_DIR").filter(|path| !path.is_empty());
if let Some(path) = &env_session_dir {
push_unique_scan_task(
&mut tasks,
&mut seen_scan_roots,
ClientId::Senpi,
PathBuf::from(path),
);
}
if let Ok(current_dir) = std::env::current_dir() {
push_unique_scan_task(
&mut tasks,
&mut seen_scan_roots,
ClientId::Senpi,
senpi_omo_children_root(¤t_dir, user_state_dir.as_deref()),
);
}
let mut sessions_roots = vec![PathBuf::from(senpi_path)];
if let Some(path) = &env_session_dir {
sessions_roots.push(PathBuf::from(path));
}
for sessions_root in sessions_roots {
for path in
discover_senpi_omo_children_roots(&sessions_root, user_state_dir.as_deref())
{
push_unique_scan_task(&mut tasks, &mut seen_scan_roots, ClientId::Senpi, path);
}
}
}
push_unique_scan_task(
&mut tasks,
&mut seen_scan_roots,
ClientId::Senpi,
senpi_omo_children_root(Path::new(home_dir), user_state_dir.as_deref()),
);
}
if enabled.contains(&ClientId::Gjc) {
let mut gjc_roots: Vec<PathBuf> = Vec::new();
let agent_dir_root = ClientId::Gjc
.data()
.resolve_path_with_env_strategy(home_dir, use_env_roots);
gjc_roots.push(PathBuf::from(agent_dir_root));
if use_env_roots {
for var in ["GJC_CONFIG_DIR", "PI_CONFIG_DIR"] {
if let Ok(config_dir) = std::env::var(var) {
let trimmed = config_dir.trim();
if !trimmed.is_empty() {
gjc_roots.push(
PathBuf::from(trimmed.trim_end_matches('/')).join("agent/sessions"),
);
}
}
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
if let Ok(xdg_data) = std::env::var("XDG_DATA_HOME") {
let trimmed = xdg_data.trim();
if !trimmed.is_empty() {
gjc_roots
.push(PathBuf::from(trimmed.trim_end_matches('/')).join("gjc/sessions"));
}
}
}
gjc_roots.push(PathBuf::from(format!("{}/.gjc/agent/sessions", home_dir)));
for root in gjc_roots {
if root.exists() {
push_unique_scan_task(&mut tasks, &mut seen_scan_roots, ClientId::Gjc, root);
}
}
}
let scan = |tasks: Vec<(ClientId, String, &str)>| {
tasks
.into_par_iter()
.map(|(client_id, path, pattern)| {
let files = scan_directory(&path, pattern);
(client_id, files)
})
.collect()
};
let scan_workers = std::thread::available_parallelism()
.map_or(2, |cores| cores.get().min(SCAN_WORKER_CEILING));
let scan_results: Vec<(ClientId, Vec<PathBuf>)> = match rayon::ThreadPoolBuilder::new()
.num_threads(scan_workers)
.thread_name(|i| format!("tokmesh-scan-{i}"))
.build()
{
Ok(pool) => pool.install(|| scan(tasks)),
Err(_) => scan(tasks),
};
let mut seen: HashSet<(ClientId, PathBuf)> = HashSet::new();
for (client_id, files) in scan_results {
for file in files {
if seen.insert((client_id, file.clone())) {
result.get_mut(client_id).push(file);
}
}
}
if enabled.contains(&ClientId::OpenClaw) {
let mut seen_openclaw_dbs: HashSet<PathBuf> = HashSet::new();
for root in &openclaw_agent_roots {
for db_path in discover_openclaw_agent_dbs(root) {
let key = std::fs::canonicalize(&db_path).unwrap_or_else(|_| db_path.clone());
if seen_openclaw_dbs.insert(key) {
result.openclaw_dbs.push(db_path);
}
}
}
result.openclaw_dbs.sort_unstable();
}
if enabled.contains(&ClientId::OpenClaw) && !result.get(ClientId::Codex).is_empty() {
let openclaw_rollouts: HashSet<PathBuf> = result
.get(ClientId::OpenClaw)
.iter()
.filter(|path| {
crate::sessions::openclaw::classify_openclaw_jsonl(path)
== crate::sessions::openclaw::OpenClawJsonlKind::CodexRollout
})
.map(|path| std::fs::canonicalize(path).unwrap_or_else(|_| path.clone()))
.collect();
if !openclaw_rollouts.is_empty() {
result.get_mut(ClientId::Codex).retain(|path| {
let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.clone());
!openclaw_rollouts.contains(&key)
});
}
}
if enabled.contains(&ClientId::Copilot) {
let desktop_db = PathBuf::from(format!("{}/.copilot/data.db", home_dir));
if desktop_db.is_file() {
result.copilot_desktop_db = Some(desktop_db);
}
result.copilot_vscode_sessions = discover_copilot_vscode_sessions(home_dir, use_env_roots);
if let Some(path) = copilot_exporter_path_with_env_strategy(use_env_roots) {
if path.is_file() && seen.insert((ClientId::Copilot, path.clone())) {
let copilot_files = result.get_mut(ClientId::Copilot);
copilot_files.push(path);
copilot_files.sort_unstable();
}
}
}
result
}
pub fn scan_all_clients(home_dir: &str, clients: &[String]) -> ScanResult {
scan_all_clients_with_env_strategy(home_dir, clients, true)
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::fs::{self, File};
use std::io::Write;
use tempfile::TempDir;
struct EnvGuard(Vec<(&'static str, Option<std::ffi::OsString>)>);
impl EnvGuard {
fn capture(keys: &[&'static str]) -> Self {
Self(
keys.iter()
.map(|key| (*key, std::env::var_os(key)))
.collect(),
)
}
fn set(&mut self, key: &'static str, value: impl AsRef<std::ffi::OsStr>) {
unsafe { std::env::set_var(key, value) };
}
fn remove(&mut self, key: &'static str) {
unsafe { std::env::remove_var(key) };
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe {
for (key, previous) in self.0.drain(..) {
match previous {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
}
}
}
}
fn scan_without_extra_dirs(home_dir: &str, clients: &[String]) -> ScanResult {
let mut extra = EnvGuard::capture(&["TOKMESH_EXTRA_DIRS", "TOKMESH_HEADLESS_DIR"]);
extra.remove("TOKMESH_EXTRA_DIRS");
extra.remove("TOKMESH_HEADLESS_DIR");
scan_all_clients(home_dir, clients)
}
fn restore_env(var: &str, previous: Option<String>) {
match previous {
Some(value) => unsafe { std::env::set_var(var, value) },
None => unsafe { std::env::remove_var(var) },
}
}
fn restore_current_dir(previous: &Path) {
std::env::set_current_dir(previous).unwrap();
}
fn setup_mock_copilot_dir(home: &Path) {
let sessions_dir = home.join(".copilot/otel");
fs::create_dir_all(&sessions_dir).unwrap();
let file_path = sessions_dir.join("copilot.jsonl");
let mut file = File::create(file_path).unwrap();
writeln!(file, "{{\"type\":\"span\",\"name\":\"chat gpt-5.4-mini\"}}").unwrap();
}
#[test]
#[serial]
fn test_env_guard_restores_after_unwind() {
const KEY: &str = "TOKMESH_SCANNER_ENV_GUARD_SELF_CHECK";
let mut outer = EnvGuard::capture(&[KEY]);
outer.set(KEY, "before");
let result = std::panic::catch_unwind(|| {
let mut inner = EnvGuard::capture(&[KEY]);
inner.set(KEY, "during");
panic!("exercise EnvGuard unwinding");
});
assert!(result.is_err());
assert_eq!(std::env::var_os(KEY), Some("before".into()));
}
#[test]
fn test_scan_result_total_files() {
let mut result = ScanResult::default();
result
.get_mut(ClientId::OpenCode)
.push(PathBuf::from("a.json"));
result
.get_mut(ClientId::OpenCode)
.push(PathBuf::from("b.json"));
result
.get_mut(ClientId::Claude)
.push(PathBuf::from("c.jsonl"));
result
.get_mut(ClientId::Gemini)
.push(PathBuf::from("d.json"));
result.get_mut(ClientId::Pi).push(PathBuf::from("e.jsonl"));
assert_eq!(result.total_files(), 5);
}
#[test]
fn test_scan_result_all_files() {
let mut result = ScanResult::default();
result
.get_mut(ClientId::OpenCode)
.push(PathBuf::from("a.json"));
result
.get_mut(ClientId::Claude)
.push(PathBuf::from("b.jsonl"));
result
.get_mut(ClientId::Codex)
.push(PathBuf::from("c.jsonl"));
result
.get_mut(ClientId::Gemini)
.push(PathBuf::from("d.json"));
result
.get_mut(ClientId::Cursor)
.push(PathBuf::from("e.csv"));
result.get_mut(ClientId::Pi).push(PathBuf::from("f.jsonl"));
let all = result.all_files();
assert_eq!(all.len(), 6);
assert_eq!(all[0], (ClientId::OpenCode, PathBuf::from("a.json")));
assert_eq!(all[1], (ClientId::Claude, PathBuf::from("b.jsonl")));
assert_eq!(all[2], (ClientId::Codex, PathBuf::from("c.jsonl")));
assert_eq!(all[3], (ClientId::Cursor, PathBuf::from("e.csv")));
assert_eq!(all[4], (ClientId::Gemini, PathBuf::from("d.json")));
assert_eq!(all[5], (ClientId::Pi, PathBuf::from("f.jsonl")));
}
#[test]
fn test_scan_result_empty() {
let result = ScanResult::default();
assert_eq!(result.total_files(), 0);
assert!(result.all_files().is_empty());
}
#[test]
fn test_client_id_equality() {
assert_eq!(ClientId::OpenCode, ClientId::OpenCode);
assert_ne!(ClientId::OpenCode, ClientId::Claude);
}
#[test]
fn test_scan_directory_json_pattern() {
let dir = TempDir::new().unwrap();
let path = dir.path();
File::create(path.join("test1.json")).unwrap();
File::create(path.join("test2.json")).unwrap();
File::create(path.join("data.txt")).unwrap();
File::create(path.join("other.jsonl")).unwrap();
let json_files = scan_directory(path.to_str().unwrap(), "*.json");
assert_eq!(json_files.len(), 2);
assert!(json_files.iter().all(|p| p.extension().unwrap() == "json"));
}
#[test]
fn test_scan_directory_json_or_jsonl_pattern() {
let dir = TempDir::new().unwrap();
let path = dir.path();
File::create(path.join("session.json")).unwrap();
File::create(path.join("session.jsonl")).unwrap();
File::create(path.join("session.txt")).unwrap();
let session_files = scan_directory(path.to_str().unwrap(), "*.json|*.jsonl");
assert_eq!(session_files.len(), 2);
assert_eq!(
session_files
.iter()
.map(|path| path.file_name().unwrap().to_str().unwrap())
.collect::<Vec<_>>(),
vec!["session.json", "session.jsonl"]
);
}
#[test]
fn test_scan_directory_jsonl_pattern() {
let dir = TempDir::new().unwrap();
let path = dir.path();
File::create(path.join("session.jsonl")).unwrap();
File::create(path.join("log.jsonl")).unwrap();
File::create(path.join("data.json")).unwrap();
let jsonl_files = scan_directory(path.to_str().unwrap(), "*.jsonl");
assert_eq!(jsonl_files.len(), 2);
assert!(jsonl_files
.iter()
.all(|p| p.extension().unwrap() == "jsonl"));
}
#[test]
fn test_scan_directory_log_pattern() {
let dir = TempDir::new().unwrap();
let path = dir.path();
File::create(path.join("ide.log")).unwrap();
File::create(path.join("vscode.log")).unwrap();
File::create(path.join("session.jsonl")).unwrap();
let log_files = scan_directory(path.to_str().unwrap(), "*.log");
assert_eq!(log_files.len(), 2);
assert!(log_files.iter().all(|p| p.extension().unwrap() == "log"));
}
#[test]
fn test_scan_directory_workbuddy_db_pattern() {
let dir = TempDir::new().unwrap();
let path = dir.path();
File::create(path.join("workbuddy.db")).unwrap();
File::create(path.join("workbuddy.db-wal")).unwrap();
File::create(path.join("workbuddy.db-shm")).unwrap();
let db_files = scan_directory(path.to_str().unwrap(), "workbuddy.db");
assert_eq!(db_files, vec![path.join("workbuddy.db")]);
}
#[test]
fn test_scan_directory_updates_jsonl_pattern() {
let dir = TempDir::new().unwrap();
let path = dir.path();
let session_dir = path.join("workspace/session-1");
fs::create_dir_all(&session_dir).unwrap();
File::create(session_dir.join("updates.jsonl")).unwrap();
File::create(session_dir.join("events.jsonl")).unwrap();
File::create(session_dir.join("updates.json")).unwrap();
let updates_files = scan_directory(path.to_str().unwrap(), "updates.jsonl");
assert_eq!(updates_files.len(), 1);
assert!(updates_files[0].ends_with("updates.jsonl"));
}
#[test]
fn test_scan_directory_session_pattern() {
let dir = TempDir::new().unwrap();
let path = dir.path();
File::create(path.join("session-001.json")).unwrap();
File::create(path.join("session-abc.json")).unwrap();
File::create(path.join("other.json")).unwrap();
File::create(path.join("session.json")).unwrap();
let session_files = scan_directory(path.to_str().unwrap(), "session-*.json");
assert_eq!(session_files.len(), 2);
assert!(session_files.iter().all(|p| {
let name = p.file_name().unwrap().to_str().unwrap();
name.starts_with("session-") && name.ends_with(".json")
}));
}
#[test]
fn test_scan_directory_ui_messages_pattern() {
let dir = TempDir::new().unwrap();
let path = dir.path();
let tasks = path.join("tasks");
fs::create_dir_all(tasks.join("task-a")).unwrap();
fs::create_dir_all(tasks.join("task-b")).unwrap();
fs::create_dir_all(tasks.join("task-c")).unwrap();
File::create(tasks.join("task-a").join("ui_messages.json")).unwrap();
File::create(tasks.join("task-b").join("ui_messages.json")).unwrap();
File::create(tasks.join("task-c").join("api_conversation_history.json")).unwrap();
let files = scan_directory(path.to_str().unwrap(), "ui_messages.json");
assert_eq!(files.len(), 2);
assert!(files.iter().all(|p| {
p.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default()
== "ui_messages.json"
}));
}
#[test]
fn test_scan_directory_nested() {
let dir = TempDir::new().unwrap();
let path = dir.path();
let sub1 = path.join("project1");
let sub2 = path.join("project2");
fs::create_dir_all(&sub1).unwrap();
fs::create_dir_all(&sub2).unwrap();
File::create(sub1.join("session.json")).unwrap();
File::create(sub2.join("session.json")).unwrap();
File::create(path.join("root.json")).unwrap();
let files = scan_directory(path.to_str().unwrap(), "*.json");
assert_eq!(files.len(), 3);
}
#[test]
fn test_scan_directory_csv_pattern() {
let dir = TempDir::new().unwrap();
let path = dir.path();
File::create(path.join("usage.csv")).unwrap();
File::create(path.join("data.csv")).unwrap();
File::create(path.join("other.json")).unwrap();
let csv_files = scan_directory(path.to_str().unwrap(), "*.csv");
assert_eq!(csv_files.len(), 2);
assert!(csv_files.iter().all(|p| p.extension().unwrap() == "csv"));
}
#[test]
fn test_scan_directory_usage_json_pattern() {
let dir = TempDir::new().unwrap();
let path = dir.path();
let archive = path.join("archive");
fs::create_dir_all(&archive).unwrap();
File::create(path.join("usage.json")).unwrap();
File::create(path.join("usage.account.json")).unwrap();
File::create(path.join("usage.backup-20240601.json")).unwrap();
File::create(path.join("other.json")).unwrap();
File::create(archive.join("usage.json")).unwrap();
let usage_files = scan_directory(path.to_str().unwrap(), "usage*.json");
let names: Vec<_> = usage_files
.iter()
.map(|path| path.file_name().unwrap().to_str().unwrap())
.collect();
assert_eq!(names, vec!["usage.account.json", "usage.json"]);
}
#[test]
fn test_scan_directory_kiro_globalstorage_pattern() {
let dir = TempDir::new().unwrap();
let path = dir.path();
let root = path.join("Library/Application Support/Kiro/User/globalStorage/kiro.kiroagent");
let workspace = root.join("workspace-a");
fs::create_dir_all(&workspace).unwrap();
File::create(workspace.join("execution.chat")).unwrap();
File::create(workspace.join("session.json")).unwrap();
File::create(workspace.join("execution")).unwrap();
File::create(workspace.join("index.sqlite")).unwrap();
let files = scan_directory(root.to_str().unwrap(), "kiro-globalstorage");
let names: Vec<_> = files
.iter()
.map(|path| path.file_name().unwrap().to_str().unwrap())
.collect();
assert_eq!(names, vec!["execution", "execution.chat", "session.json"]);
}
#[test]
fn test_scan_directory_kiro_ide_session_pattern() {
let dir = TempDir::new().unwrap();
let sessions_root = dir.path().join(".kiro/sessions");
let sess_dir = sessions_root.join("workspace-a/sess_02f1c107");
fs::create_dir_all(&sess_dir).unwrap();
File::create(sess_dir.join("session.json")).unwrap();
File::create(sess_dir.join("messages.jsonl")).unwrap();
let cli_dir = sessions_root.join("cli");
fs::create_dir_all(&cli_dir).unwrap();
File::create(cli_dir.join("session-001.json")).unwrap();
File::create(sessions_root.join("workspace-a/session.json")).unwrap();
let files = scan_directory(sessions_root.to_str().unwrap(), "kiro-ide-session");
let names: Vec<_> = files
.iter()
.map(|path| {
path.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap()
.to_string()
})
.collect();
assert_eq!(files.len(), 1);
assert_eq!(names, vec!["sess_02f1c107"]);
}
#[test]
fn test_scan_directory_nonexistent() {
let files = scan_directory("/nonexistent/path/that/does/not/exist", "*.json");
assert!(files.is_empty());
}
#[test]
fn test_scan_all_clients_discovers_zcode_v2_sqlite() {
let dir = TempDir::new().unwrap();
let db_dir = dir.path().join(".zcode/cli/db");
fs::create_dir_all(&db_dir).unwrap();
let db_path = db_dir.join("db.sqlite");
File::create(&db_path).unwrap();
let result = scan_all_clients_with_env_strategy(
dir.path().to_str().unwrap(),
&["zcode".to_string()],
false,
);
assert_eq!(result.zcode_db.as_deref(), Some(db_path.as_path()));
}
#[test]
fn test_scan_all_clients_discovers_codebuddy_extension_logs() {
let dir = TempDir::new().unwrap();
let ide_dir = dir
.path()
.join("AppData")
.join("Local")
.join("CodeBuddyExtension")
.join("Logs")
.join("CodeBuddyIDE")
.join("2026-07-01");
let vscode_dir = dir
.path()
.join("AppData")
.join("Local")
.join("CodeBuddyExtension")
.join("Logs")
.join("VSCode")
.join("2026-07-01");
fs::create_dir_all(&ide_dir).unwrap();
fs::create_dir_all(&vscode_dir).unwrap();
let ide_log = ide_dir.join("ide.log");
let vscode_log = vscode_dir.join("vscode.log");
File::create(&ide_log).unwrap();
File::create(&vscode_log).unwrap();
let result = scan_all_clients_with_env_strategy(
dir.path().to_str().unwrap(),
&["codebuddy".to_string()],
false,
);
let files = result.get(ClientId::CodeBuddy);
assert_eq!(files.len(), 2);
assert!(files.contains(&ide_log));
assert!(files.contains(&vscode_log));
}
#[test]
fn test_scan_all_clients_discovers_workbuddy_project_jsonl() {
let dir = TempDir::new().unwrap();
let project_dir = dir.path().join(".workbuddy/projects/project-a");
fs::create_dir_all(&project_dir).unwrap();
let session = project_dir.join("session.jsonl");
File::create(&session).unwrap();
let result = scan_all_clients_with_env_strategy(
dir.path().to_str().unwrap(),
&["workbuddy".to_string()],
false,
);
let files = result.get(ClientId::WorkBuddy);
assert_eq!(files.as_slice(), std::slice::from_ref(&session));
}
#[test]
fn test_scan_directory_empty() {
let dir = TempDir::new().unwrap();
let files = scan_directory(dir.path().to_str().unwrap(), "*.json");
assert!(files.is_empty());
}
#[test]
fn test_scan_directory_deterministic_order() {
let dir = TempDir::new().unwrap();
let path = dir.path();
for name in ["zebra.jsonl", "alpha.jsonl", "middle.jsonl", "beta.jsonl"] {
File::create(path.join(name)).unwrap();
}
let first = scan_directory(path.to_str().unwrap(), "*.jsonl");
let second = scan_directory(path.to_str().unwrap(), "*.jsonl");
let third = scan_directory(path.to_str().unwrap(), "*.jsonl");
assert_eq!(first, second, "Repeated scans must return identical order");
assert_eq!(second, third, "Repeated scans must return identical order");
let names: Vec<_> = first
.iter()
.map(|p| p.file_name().unwrap().to_str().unwrap())
.collect();
assert_eq!(
names,
vec!["alpha.jsonl", "beta.jsonl", "middle.jsonl", "zebra.jsonl"],
"Results must be lexically sorted"
);
}
fn setup_mock_opencode_dir(base: &std::path::Path) {
let opencode_path = base.join(".local/share/opencode/storage/message/proj1");
fs::create_dir_all(&opencode_path).unwrap();
let mut file = File::create(opencode_path.join("msg_001.json")).unwrap();
file.write_all(b"{}").unwrap();
}
fn setup_mock_claude_dir(base: &std::path::Path) {
let claude_path = base.join(".claude/projects/myproject");
fs::create_dir_all(&claude_path).unwrap();
let mut file = File::create(claude_path.join("conversation.jsonl")).unwrap();
file.write_all(b"").unwrap();
}
fn setup_mock_claude_transcripts_dir(base: &std::path::Path) -> PathBuf {
let transcript_path = base.join(".claude/transcripts");
fs::create_dir_all(&transcript_path).unwrap();
let file_path = transcript_path.join("ses_123456789012345678901234567.jsonl");
let mut file = File::create(&file_path).unwrap();
file.write_all(b"").unwrap();
file_path
}
fn setup_mock_codex_dir(base: &std::path::Path) {
let codex_path = base.join(".codex/sessions");
fs::create_dir_all(&codex_path).unwrap();
let mut file = File::create(codex_path.join("session.jsonl")).unwrap();
file.write_all(b"").unwrap();
}
fn setup_mock_codex_archived_dir(base: &std::path::Path) {
let archived_path = base.join(".codex/archived_sessions");
fs::create_dir_all(&archived_path).unwrap();
let mut file = File::create(archived_path.join("archived.jsonl")).unwrap();
file.write_all(b"").unwrap();
}
fn setup_mock_gemini_dir(base: &std::path::Path) {
let gemini_path = base.join(".gemini/tmp/123/chats");
fs::create_dir_all(&gemini_path).unwrap();
let mut file = File::create(gemini_path.join("session-abc.json")).unwrap();
file.write_all(b"{}").unwrap();
}
fn setup_mock_pi_dir(base: &std::path::Path) {
let pi_path = base.join(".pi/agent/sessions/--test--");
fs::create_dir_all(&pi_path).unwrap();
let mut file = File::create(pi_path.join("1733011200000_pi_ses_001.jsonl")).unwrap();
file.write_all(b"{}").unwrap();
}
fn setup_mock_kiro_dir(base: &std::path::Path) {
let kiro_path = base.join(".kiro/sessions/cli");
fs::create_dir_all(&kiro_path).unwrap();
File::create(kiro_path.join("session-001.json")).unwrap();
}
fn setup_mock_kiro_global_storage_dir(base: &std::path::Path) {
let root = base.join("Library/Application Support/Kiro/User/globalStorage/kiro.kiroagent");
let workspace = root.join("workspace-a");
fs::create_dir_all(&workspace).unwrap();
File::create(workspace.join("execution.chat")).unwrap();
File::create(workspace.join("session.json")).unwrap();
File::create(workspace.join("execution")).unwrap();
}
fn setup_mock_omp_dir(base: &std::path::Path) {
let omp_path = base.join(".omp/agent/sessions/--omp-test--");
fs::create_dir_all(&omp_path).unwrap();
let mut file =
File::create(omp_path.join("2026-04-06T03-04-28Z_omp_ses_001.jsonl")).unwrap();
file.write_all(b"{}").unwrap();
}
fn setup_mock_zed_xdg_db(base: &std::path::Path) -> PathBuf {
let zed_db = base.join(".local/share/zed/threads/threads.db");
fs::create_dir_all(zed_db.parent().unwrap()).unwrap();
File::create(&zed_db).unwrap();
zed_db
}
#[cfg(target_os = "macos")]
fn setup_mock_zed_macos_db(base: &std::path::Path) -> PathBuf {
let zed_db = base.join("Library/Application Support/Zed/threads/threads.db");
fs::create_dir_all(zed_db.parent().unwrap()).unwrap();
File::create(&zed_db).unwrap();
zed_db
}
fn setup_mock_kimi_dir(base: &std::path::Path) {
let kimi_session = base.join(".kimi/sessions/group1/session-uuid-1");
fs::create_dir_all(&kimi_session).unwrap();
let mut file = File::create(kimi_session.join("wire.jsonl")).unwrap();
file.write_all(b"{\"type\": \"metadata\", \"protocol_version\": \"1.3\"}\n")
.unwrap();
}
fn setup_mock_grok_dir(base: &std::path::Path) {
let grok_session = base.join(".grok/sessions/%2Ftmp%2Fproject/session-uuid-1");
fs::create_dir_all(&grok_session).unwrap();
let mut file = File::create(grok_session.join("updates.jsonl")).unwrap();
file.write_all(b"{\"method\":\"session/update\"}\n")
.unwrap();
}
fn setup_mock_jcode_dir(base: &std::path::Path) {
let jcode_sessions = base.join(".jcode/sessions");
fs::create_dir_all(&jcode_sessions).unwrap();
File::create(jcode_sessions.join("session_fixture.json")).unwrap();
File::create(jcode_sessions.join("not-a-session.json")).unwrap();
}
fn setup_mock_openclaw_dir(base: &std::path::Path) {
let openclaw_sessions = base.join(".openclaw/agents/main/sessions");
fs::create_dir_all(&openclaw_sessions).unwrap();
let mut transcript = File::create(openclaw_sessions.join("session-abc.jsonl")).unwrap();
transcript.write_all(b"{}").unwrap();
let mut archived_deleted =
File::create(openclaw_sessions.join("session-deleted.jsonl.deleted.123")).unwrap();
archived_deleted.write_all(b"{}").unwrap();
let mut archived_reset =
File::create(openclaw_sessions.join("session-reset.jsonl.reset.456")).unwrap();
archived_reset.write_all(b"{}").unwrap();
let mut index = File::create(openclaw_sessions.join("sessions.json")).unwrap();
index.write_all(b"{}").unwrap();
}
fn setup_mock_openclaw_transcript_copies(base: &std::path::Path) {
let openclaw_sessions = base.join(".openclaw/agents/main/sessions");
fs::create_dir_all(&openclaw_sessions).unwrap();
for name in [
"session-backup.jsonl.pre-doctor-openai-codex-repair-2026-07-01T15-35-38-171Z.bak",
"session-broken.jsonl.broken-empty-input-20260428T0728Z",
"session-json.jsonl.pre-doctor-json-shape-repair-2026-07-01T15-35-38-171Z.bak",
"session-abc.jsonl.codex-app-server.json.migrated",
"session-abc.jsonl.codex-app-server.json",
] {
File::create(openclaw_sessions.join(name)).unwrap();
}
}
fn setup_mock_roocode_dir(base: &std::path::Path) {
let local = base
.join(".config/Code/User/globalStorage/rooveterinaryinc.roo-cline/tasks/task-local");
let server = base.join(
".vscode-server/data/User/globalStorage/rooveterinaryinc.roo-cline/tasks/task-server",
);
fs::create_dir_all(&local).unwrap();
fs::create_dir_all(&server).unwrap();
File::create(local.join("ui_messages.json")).unwrap();
File::create(server.join("ui_messages.json")).unwrap();
}
fn setup_mock_kilocode_dir(base: &std::path::Path) {
let local =
base.join(".config/Code/User/globalStorage/kilocode.kilo-code/tasks/task-local");
let server = base
.join(".vscode-server/data/User/globalStorage/kilocode.kilo-code/tasks/task-server");
fs::create_dir_all(&local).unwrap();
fs::create_dir_all(&server).unwrap();
File::create(local.join("ui_messages.json")).unwrap();
File::create(server.join("ui_messages.json")).unwrap();
}
fn setup_mock_cline_dir(base: &std::path::Path) {
let local =
base.join(".config/Code/User/globalStorage/saoudrizwan.claude-dev/tasks/task-local");
let macos = base.join(
"Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev/tasks/task-macos",
);
let windows = base.join(
"AppData/Roaming/Code/User/globalStorage/saoudrizwan.claude-dev/tasks/task-windows",
);
let server = base.join(
".vscode-server/data/User/globalStorage/saoudrizwan.claude-dev/tasks/task-server",
);
fs::create_dir_all(&local).unwrap();
fs::create_dir_all(&macos).unwrap();
fs::create_dir_all(&windows).unwrap();
fs::create_dir_all(&server).unwrap();
File::create(local.join("ui_messages.json")).unwrap();
File::create(macos.join("ui_messages.json")).unwrap();
File::create(windows.join("ui_messages.json")).unwrap();
File::create(server.join("ui_messages.json")).unwrap();
}
fn setup_mock_crush_registry(registry_path: &Path, projects_json: &str) {
fs::create_dir_all(registry_path.parent().unwrap()).unwrap();
fs::write(registry_path, projects_json).unwrap();
}
#[test]
#[serial]
fn test_headless_roots_default() {
let previous = std::env::var("TOKMESH_HEADLESS_DIR").ok();
unsafe { std::env::remove_var("TOKMESH_HEADLESS_DIR") };
let home = "/tmp/tokmesh-test-home";
let roots = headless_roots(home);
let config_root = PathBuf::from(format!("{}/.config/tokmesh/headless", home));
assert_eq!(roots, vec![config_root]);
restore_env("TOKMESH_HEADLESS_DIR", previous);
}
#[test]
#[serial]
fn test_headless_roots_override() {
let previous = std::env::var("TOKMESH_HEADLESS_DIR").ok();
unsafe { std::env::set_var("TOKMESH_HEADLESS_DIR", "/custom/headless") };
let roots = headless_roots("/tmp/home");
assert_eq!(roots, vec![PathBuf::from("/custom/headless")]);
restore_env("TOKMESH_HEADLESS_DIR", previous);
}
#[test]
#[serial]
fn test_headless_roots_ignore_env_override_when_disabled() {
let previous = std::env::var("TOKMESH_HEADLESS_DIR").ok();
unsafe { std::env::set_var("TOKMESH_HEADLESS_DIR", "/custom/headless") };
let roots = headless_roots_with_env_strategy("/tmp/home", false);
assert_eq!(
roots,
vec![PathBuf::from("/tmp/home/.config/tokmesh/headless")]
);
restore_env("TOKMESH_HEADLESS_DIR", previous);
}
#[test]
#[serial]
fn test_scan_all_clients_opencode() {
let previous_xdg = std::env::var("XDG_DATA_HOME").ok();
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_opencode_dir(home);
unsafe { std::env::set_var("XDG_DATA_HOME", home.join(".local/share")) };
let result = scan_without_extra_dirs(home.to_str().unwrap(), &["opencode".to_string()]);
assert_eq!(result.get(ClientId::OpenCode).len(), 1);
assert!(result.get(ClientId::Claude).is_empty());
assert!(result.get(ClientId::Codex).is_empty());
assert!(result.get(ClientId::Gemini).is_empty());
restore_env("XDG_DATA_HOME", previous_xdg);
}
#[test]
#[serial]
fn test_scan_all_clients_opencode_home_override_ignores_xdg_env() {
let previous_xdg = std::env::var("XDG_DATA_HOME").ok();
let dir = TempDir::new().unwrap();
let home = dir.path().join("target-home");
let conflicting_xdg = dir.path().join("conflicting-xdg");
setup_mock_opencode_dir(&home);
fs::create_dir_all(&conflicting_xdg).unwrap();
unsafe { std::env::set_var("XDG_DATA_HOME", &conflicting_xdg) };
let result = scan_all_clients_with_env_strategy(
home.to_str().unwrap(),
&["opencode".to_string()],
false,
);
assert_eq!(result.get(ClientId::OpenCode).len(), 1);
assert_eq!(
result.opencode_json_dir,
Some(home.join(".local/share/opencode/storage/message"))
);
restore_env("XDG_DATA_HOME", previous_xdg);
}
#[test]
fn test_is_opencode_db_filename_accepts_default_and_channel_variants() {
assert!(is_opencode_db_filename("opencode.db"));
assert!(is_opencode_db_filename("opencode-stable.db"));
assert!(is_opencode_db_filename("opencode-nightly.db"));
assert!(is_opencode_db_filename("opencode-canary.db"));
assert!(is_opencode_db_filename("opencode-local.db"));
assert!(is_opencode_db_filename("opencode-1.2.3.db"));
assert!(is_opencode_db_filename("opencode-pr_42.db"));
}
#[test]
fn test_is_opencode_db_filename_rejects_sidecars_and_unrelated_files() {
assert!(!is_opencode_db_filename("opencode.db-wal"));
assert!(!is_opencode_db_filename("opencode.db-shm"));
assert!(!is_opencode_db_filename("opencode.db-journal"));
assert!(!is_opencode_db_filename("opencode-stable.db-wal"));
assert!(!is_opencode_db_filename("opencode"));
assert!(!is_opencode_db_filename("opencode-.db"));
assert!(!is_opencode_db_filename("opencode_stable.db"));
assert!(!is_opencode_db_filename("opencode-stable/beta.db"));
assert!(!is_opencode_db_filename("auth.json"));
assert!(!is_opencode_db_filename("other.db"));
}
#[test]
fn test_is_micode_db_filename_accepts_default_and_channel_rejects_sidecars() {
assert!(is_micode_db_filename("mimocode.db"));
assert!(is_micode_db_filename("mimocode-stable.db"));
assert!(is_micode_db_filename("mimocode-nightly.db"));
assert!(!is_micode_db_filename("mimocode.db-wal"));
assert!(!is_micode_db_filename("mimocode.db-shm"));
}
#[test]
fn test_discover_micode_dbs_in_dirs_unions_xdg_and_orca_roots() {
let dir = TempDir::new().unwrap();
let xdg_dir = dir.path().join(".local/share/mimocode");
fs::create_dir_all(&xdg_dir).unwrap();
let xdg_db = xdg_dir.join("mimocode.db");
fs::write(&xdg_db, b"").unwrap();
let orca_dir = dir
.path()
.join("Library/Application Support/orca/mimocode-hooks/shared/data");
fs::create_dir_all(&orca_dir).unwrap();
let orca_db = orca_dir.join("mimocode.db");
let orca_channel_db = orca_dir.join("mimocode-nightly.db");
fs::write(&orca_db, b"").unwrap();
fs::write(&orca_channel_db, b"").unwrap();
fs::write(orca_dir.join("mimocode.db-wal"), b"").unwrap();
let dbs = discover_micode_dbs_in_dirs([xdg_dir, orca_dir]);
assert!(dbs.contains(&xdg_db), "XDG db should be discovered");
assert!(dbs.contains(&orca_db), "orca db should be discovered");
assert!(
dbs.contains(&orca_channel_db),
"orca channel db should be discovered"
);
assert_eq!(dbs.len(), 3, "no sidecar files, no missed dbs");
}
#[test]
fn test_discover_micode_dbs_in_dirs_collapses_same_file_via_symlink() {
let dir = TempDir::new().unwrap();
let real_dir = dir.path().join("real/mimocode");
fs::create_dir_all(&real_dir).unwrap();
let real_db = real_dir.join("mimocode.db");
fs::write(&real_db, b"").unwrap();
let link_dir = dir.path().join("linked");
#[cfg(unix)]
std::os::unix::fs::symlink(&real_dir, &link_dir).unwrap();
#[cfg(not(unix))]
std::os::windows::fs::symlink_dir(&real_dir, &link_dir).unwrap();
let dbs = discover_micode_dbs_in_dirs([real_dir, link_dir]);
assert_eq!(dbs.len(), 1, "symlinked duplicate must collapse to one db");
}
#[test]
fn test_discover_opencode_dbs_finds_multiple_channels_and_skips_sidecars() {
let dir = TempDir::new().unwrap();
let data_dir = dir.path().join("opencode");
fs::create_dir_all(&data_dir).unwrap();
File::create(data_dir.join("opencode.db")).unwrap();
File::create(data_dir.join("opencode-stable.db")).unwrap();
File::create(data_dir.join("opencode.db-wal")).unwrap();
File::create(data_dir.join("opencode.db-shm")).unwrap();
File::create(data_dir.join("opencode-stable.db-wal")).unwrap();
File::create(data_dir.join("auth.json")).unwrap();
let found = discover_opencode_dbs(&data_dir);
let names: Vec<String> = found
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert_eq!(names, vec!["opencode-stable.db", "opencode.db"]);
}
#[test]
fn test_discover_opencode_dbs_returns_empty_for_missing_dir() {
let dir = TempDir::new().unwrap();
let missing = dir.path().join("does-not-exist");
assert!(discover_opencode_dbs(&missing).is_empty());
}
#[test]
fn test_merge_user_opencode_db_paths_picks_up_path_outside_xdg() {
let dir = TempDir::new().unwrap();
let outside = dir.path().join("somewhere-else");
fs::create_dir_all(&outside).unwrap();
let user_db = outside.join("opencode.db");
File::create(&user_db).unwrap();
let mut discovered: Vec<PathBuf> = Vec::new();
merge_user_opencode_db_paths(&mut discovered, std::slice::from_ref(&user_db));
assert_eq!(discovered, vec![user_db]);
}
#[test]
fn test_merge_user_opencode_db_paths_skips_nonexistent_and_sidecars() {
let dir = TempDir::new().unwrap();
let real = dir.path().join("opencode-stable.db");
File::create(&real).unwrap();
let wal = dir.path().join("opencode-stable.db-wal");
File::create(&wal).unwrap();
let missing = dir.path().join("opencode-missing.db");
let mut discovered: Vec<PathBuf> = Vec::new();
merge_user_opencode_db_paths(
&mut discovered,
&[real.clone(), wal.clone(), missing.clone()],
);
assert_eq!(discovered, vec![real]);
}
#[test]
fn test_merge_user_opencode_db_paths_dedups_against_auto_discovered() {
let dir = TempDir::new().unwrap();
let shared = dir.path().join("opencode.db");
File::create(&shared).unwrap();
let mut discovered: Vec<PathBuf> = vec![shared.clone()];
merge_user_opencode_db_paths(&mut discovered, std::slice::from_ref(&shared));
assert_eq!(discovered, vec![shared]);
}
#[test]
fn test_scanner_settings_deserialize_from_json_camel_case() {
let json = r#"{
"opencodeDbPaths": ["/one/opencode.db", "/two/opencode-stable.db"]
}"#;
let parsed: ScannerSettings = serde_json::from_str(json).unwrap();
assert_eq!(parsed.opencode_db_paths.len(), 2);
assert_eq!(
parsed.opencode_db_paths[0],
PathBuf::from("/one/opencode.db")
);
assert_eq!(
parsed.opencode_db_paths[1],
PathBuf::from("/two/opencode-stable.db")
);
let empty: ScannerSettings = serde_json::from_str("{}").unwrap();
assert!(empty.opencode_db_paths.is_empty());
}
#[test]
fn test_scanner_settings_deserialize_extra_scan_paths_camel_case() {
let json = r#"{
"extraScanPaths": {
"codex": [
"/tmp/project-a/.codex/sessions",
"/tmp/project-b/.codex/archived_sessions"
],
"gemini": ["/tmp/imports/gemini/tmp"]
}
}"#;
let parsed: ScannerSettings = serde_json::from_str(json).unwrap();
let serialized = serde_json::to_value(&parsed).unwrap();
assert_eq!(
serialized["extraScanPaths"]["codex"][0],
serde_json::json!("/tmp/project-a/.codex/sessions")
);
assert_eq!(
serialized["extraScanPaths"]["codex"][1],
serde_json::json!("/tmp/project-b/.codex/archived_sessions")
);
assert_eq!(
serialized["extraScanPaths"]["gemini"][0],
serde_json::json!("/tmp/imports/gemini/tmp")
);
}
#[test]
#[serial]
fn test_scan_all_clients_with_scanner_settings_merges_user_path() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let data_dir = home.join(".local/share/opencode");
fs::create_dir_all(&data_dir).unwrap();
File::create(data_dir.join("opencode-stable.db")).unwrap();
let outside_dir = home.join("elsewhere");
fs::create_dir_all(&outside_dir).unwrap();
let outside_db = outside_dir.join("opencode.db");
File::create(&outside_db).unwrap();
let settings = ScannerSettings {
opencode_db_paths: vec![outside_db.clone()],
..Default::default()
};
let result = scan_all_clients_with_scanner_settings(
home.to_str().unwrap(),
&["opencode".to_string()],
false,
&settings,
);
let names: Vec<String> = result
.opencode_dbs
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert!(
names.iter().any(|n| n == "opencode-stable.db"),
"expected auto-discovered opencode-stable.db, got {names:?}"
);
assert!(
result.opencode_dbs.iter().any(|p| p == &outside_db),
"expected user-configured {} in {:?}",
outside_db.display(),
result.opencode_dbs
);
}
#[test]
#[serial]
fn test_scan_all_clients_with_scanner_settings_merges_settings_extra_paths() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let default_root = home.join(".codex/sessions");
fs::create_dir_all(&default_root).unwrap();
File::create(default_root.join("default.jsonl")).unwrap();
let extra_root = home.join("workspace/project-a/.codex/sessions");
fs::create_dir_all(&extra_root).unwrap();
File::create(extra_root.join("extra.jsonl")).unwrap();
let settings: ScannerSettings = serde_json::from_value(serde_json::json!({
"extraScanPaths": {
"codex": [extra_root]
}
}))
.unwrap();
let result = scan_all_clients_with_scanner_settings(
home.to_str().unwrap(),
&["codex".to_string()],
false,
&settings,
);
assert_eq!(result.get(ClientId::Codex).len(), 2);
}
#[test]
fn test_scan_all_clients_with_scanner_settings_discovers_devin_cli_extra_databases() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let default_db = home.join(".local/share/devin/cli/sessions.db");
fs::create_dir_all(default_db.parent().unwrap()).unwrap();
File::create(&default_db).unwrap();
let extra_root = home.join("imports/devin");
let extra_db = extra_root.join("profile/sessions.db");
fs::create_dir_all(extra_db.parent().unwrap()).unwrap();
File::create(&extra_db).unwrap();
let settings: ScannerSettings = serde_json::from_value(serde_json::json!({
"extraScanPaths": {
"devin-cli": [extra_root]
}
}))
.unwrap();
let result = scan_all_clients_with_scanner_settings(
home.to_str().unwrap(),
&["devin-cli".to_string()],
false,
&settings,
);
assert_eq!(result.devin_dbs, vec![default_db, extra_db]);
assert!(
result.get(ClientId::DevinCli).is_empty(),
"Devin SQLite databases should use the dedicated scan result"
);
}
#[test]
fn test_devin_desktop_scan_includes_configured_cli_lookup_databases() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let extra_root = home.join("imports/devin");
let extra_db = extra_root.join("profile/sessions.db");
fs::create_dir_all(extra_db.parent().unwrap()).unwrap();
File::create(&extra_db).unwrap();
let settings: ScannerSettings = serde_json::from_value(serde_json::json!({
"extraScanPaths": {
"devin-cli": [extra_root]
}
}))
.unwrap();
let result = scan_all_clients_with_scanner_settings(
home.to_str().unwrap(),
&["devin-desktop".to_string()],
false,
&settings,
);
assert_eq!(result.devin_dbs, vec![extra_db]);
}
#[test]
#[serial]
fn test_scan_all_clients_with_scanner_settings_merges_hermes_extra_profile_db() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let default_dir = home.join(".hermes");
fs::create_dir_all(&default_dir).unwrap();
let default_db = default_dir.join("state.db");
File::create(&default_db).unwrap();
let profile_dir = home.join(".hermes/profiles/director_planning");
fs::create_dir_all(&profile_dir).unwrap();
let profile_db = profile_dir.join("state.db");
File::create(&profile_db).unwrap();
let settings: ScannerSettings = serde_json::from_value(serde_json::json!({
"extraScanPaths": {
"hermes": [
profile_dir,
profile_db
]
}
}))
.unwrap();
let result = scan_all_clients_with_scanner_settings(
home.to_str().unwrap(),
&["hermes".to_string()],
false,
&settings,
);
assert_eq!(result.hermes_db.as_ref(), Some(&default_db));
assert_eq!(result.hermes_db_paths(), vec![default_db, profile_db]);
}
#[test]
#[serial]
fn test_scan_all_clients_with_scanner_settings_auto_discovers_hermes_profile_dbs() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let default_dir = home.join(".hermes");
fs::create_dir_all(&default_dir).unwrap();
let default_db = default_dir.join("state.db");
File::create(&default_db).unwrap();
let profile_a_dir = home.join(".hermes/profiles/director_planning");
fs::create_dir_all(&profile_a_dir).unwrap();
let profile_a_db = profile_a_dir.join("state.db");
File::create(&profile_a_db).unwrap();
let profile_b_dir = home.join(".hermes/profiles/research");
fs::create_dir_all(&profile_b_dir).unwrap();
let profile_b_db = profile_b_dir.join("state.db");
File::create(&profile_b_db).unwrap();
let nested_dir = home.join(".hermes/profiles/research/archive");
fs::create_dir_all(&nested_dir).unwrap();
File::create(nested_dir.join("state.db")).unwrap();
let result = scan_all_clients_with_scanner_settings(
home.to_str().unwrap(),
&["hermes".to_string()],
false,
&ScannerSettings::default(),
);
assert_eq!(result.hermes_db.as_ref(), Some(&default_db));
assert_eq!(
result.hermes_db_paths(),
vec![default_db, profile_a_db, profile_b_db]
);
}
#[test]
#[serial]
fn test_scan_all_clients_with_scanner_settings_auto_discovers_hermes_profiles_without_default_db(
) {
let dir = TempDir::new().unwrap();
let home = dir.path();
let profile_dir = home.join(".hermes/profiles/research");
fs::create_dir_all(&profile_dir).unwrap();
let profile_db = profile_dir.join("state.db");
File::create(&profile_db).unwrap();
let result = scan_all_clients_with_scanner_settings(
home.to_str().unwrap(),
&["hermes".to_string()],
false,
&ScannerSettings::default(),
);
assert_eq!(result.hermes_db, None);
assert_eq!(result.hermes_db_paths(), vec![profile_db]);
}
#[test]
#[serial]
fn test_scan_all_clients_with_scanner_settings_auto_discovers_hermes_profiles_under_env_home() {
let mut env = EnvGuard::capture(&["HERMES_HOME", "TOKMESH_EXTRA_DIRS"]);
env.remove("TOKMESH_EXTRA_DIRS");
let dir = TempDir::new().unwrap();
let home = dir.path();
let hermes_home = home.join("custom-hermes-home");
fs::create_dir_all(&hermes_home).unwrap();
let default_db = hermes_home.join("state.db");
File::create(&default_db).unwrap();
let profile_dir = hermes_home.join("profiles/research");
fs::create_dir_all(&profile_dir).unwrap();
let profile_db = profile_dir.join("state.db");
File::create(&profile_db).unwrap();
env.set("HERMES_HOME", &hermes_home);
let result = scan_all_clients_with_scanner_settings(
home.to_str().unwrap(),
&["hermes".to_string()],
true,
&ScannerSettings::default(),
);
assert_eq!(result.hermes_db.as_ref(), Some(&default_db));
assert_eq!(result.hermes_db_paths(), vec![default_db, profile_db]);
}
#[test]
#[serial]
fn test_scan_all_clients_with_scanner_settings_profile_scoped_hermes_home_isolates_to_own_profile(
) {
let mut env = EnvGuard::capture(&["HERMES_HOME", "TOKMESH_EXTRA_DIRS"]);
env.remove("TOKMESH_EXTRA_DIRS");
let dir = TempDir::new().unwrap();
let home = dir.path();
let profile_root = home.join(".hermes/profiles");
let default_db = home.join(".hermes/state.db");
fs::create_dir_all(default_db.parent().unwrap()).unwrap();
File::create(&default_db).unwrap();
let coder_dir = profile_root.join("coder");
fs::create_dir_all(&coder_dir).unwrap();
let coder_db = coder_dir.join("state.db");
File::create(&coder_db).unwrap();
let research_dir = profile_root.join("research");
fs::create_dir_all(&research_dir).unwrap();
let research_db = research_dir.join("state.db");
File::create(&research_db).unwrap();
let nested_dir = coder_dir.join("profiles/archived");
fs::create_dir_all(&nested_dir).unwrap();
File::create(nested_dir.join("state.db")).unwrap();
env.set("HERMES_HOME", &coder_dir);
let result = scan_all_clients_with_scanner_settings(
home.to_str().unwrap(),
&["hermes".to_string()],
true,
&ScannerSettings::default(),
);
assert_eq!(result.hermes_db.as_ref(), Some(&coder_db));
assert_eq!(result.hermes_db_paths(), vec![coder_db.clone()]);
assert!(
!result.hermes_db_paths().contains(&research_db),
"profile-scoped HERMES_HOME must not discover sibling profiles"
);
assert!(
!result.hermes_db_paths().contains(&default_db),
"profile-scoped HERMES_HOME must not discover the default profile"
);
}
#[test]
#[serial]
fn test_scan_all_clients_with_scanner_settings_discovers_hermes_windows_local_appdata_home() {
let previous_hermes_home = std::env::var("HERMES_HOME").ok();
let previous_local_app_data = std::env::var("LOCALAPPDATA").ok();
unsafe { std::env::remove_var("HERMES_HOME") };
unsafe { std::env::remove_var("LOCALAPPDATA") };
let dir = TempDir::new().unwrap();
let home = dir.path();
let windows_home = home.join("AppData/Local/hermes");
fs::create_dir_all(&windows_home).unwrap();
let default_db = windows_home.join("state.db");
File::create(&default_db).unwrap();
let profile_dir = windows_home.join("profiles/research");
fs::create_dir_all(&profile_dir).unwrap();
let profile_db = profile_dir.join("state.db");
File::create(&profile_db).unwrap();
let result = scan_all_clients_with_scanner_settings(
home.to_str().unwrap(),
&["hermes".to_string()],
false,
&ScannerSettings::default(),
);
restore_env("HERMES_HOME", previous_hermes_home);
restore_env("LOCALAPPDATA", previous_local_app_data);
assert_eq!(result.hermes_db.as_ref(), Some(&default_db));
assert_eq!(result.hermes_db_paths(), vec![default_db, profile_db]);
}
#[test]
fn test_scan_all_clients_with_scanner_settings_discovers_zed_windows_local_appdata_home() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let windows_threads_dir = home.join("AppData/Local/Zed/threads");
fs::create_dir_all(&windows_threads_dir).unwrap();
let threads_db = windows_threads_dir.join("threads.db");
File::create(&threads_db).unwrap();
let result = scan_all_clients_with_scanner_settings(
home.to_str().unwrap(),
&["zed".to_string()],
false,
&ScannerSettings::default(),
);
assert_eq!(result.zed_db.as_ref(), Some(&threads_db));
}
#[test]
fn test_scan_all_clients_with_scanner_settings_merges_zed_extra_threads_db() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let windows_threads_dir = home.join("AppData/Local/Zed/threads");
fs::create_dir_all(&windows_threads_dir).unwrap();
let threads_db = windows_threads_dir.join("threads.db");
File::create(&threads_db).unwrap();
let settings: ScannerSettings = serde_json::from_value(serde_json::json!({
"extraScanPaths": {
"zed": [windows_threads_dir]
}
}))
.unwrap();
let result = scan_all_clients_with_scanner_settings(
home.to_str().unwrap(),
&["zed".to_string()],
false,
&settings,
);
assert_eq!(result.zed_db_paths(), vec![threads_db]);
}
#[test]
#[serial]
fn test_scan_all_clients_with_scanner_settings_respects_hermes_client_filter() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let profile_dir = home.join(".hermes/profiles/director_planning");
fs::create_dir_all(&profile_dir).unwrap();
let profile_db = profile_dir.join("state.db");
File::create(&profile_db).unwrap();
let settings: ScannerSettings = serde_json::from_value(serde_json::json!({
"extraScanPaths": {
"hermes": [profile_dir]
}
}))
.unwrap();
let claude_only = scan_all_clients_with_scanner_settings(
home.to_str().unwrap(),
&["claude".to_string()],
true,
&settings,
);
assert!(claude_only.hermes_db_paths().is_empty());
let hermes_only = scan_all_clients_with_scanner_settings(
home.to_str().unwrap(),
&["hermes".to_string()],
false,
&settings,
);
assert_eq!(hermes_only.hermes_db_paths(), vec![profile_db]);
}
#[test]
#[serial]
fn test_scan_all_clients_with_scanner_settings_dedups_settings_and_env_extra_paths() {
let mut env =
EnvGuard::capture(&["TOKMESH_EXTRA_DIRS", "TOKMESH_HEADLESS_DIR", "CODEX_HOME"]);
env.remove("TOKMESH_HEADLESS_DIR");
let dir = TempDir::new().unwrap();
let home = dir.path();
env.set("CODEX_HOME", home.join(".codex"));
let default_root = home.join(".codex/sessions");
fs::create_dir_all(&default_root).unwrap();
File::create(default_root.join("default.jsonl")).unwrap();
let extra_root = home.join("workspace/project-a/.codex/sessions");
fs::create_dir_all(&extra_root).unwrap();
File::create(extra_root.join("extra.jsonl")).unwrap();
env.set(
"TOKMESH_EXTRA_DIRS",
format!("codex:{}", extra_root.join("..").join("sessions").display()),
);
let settings: ScannerSettings = serde_json::from_value(serde_json::json!({
"extraScanPaths": {
"codex": [extra_root]
}
}))
.unwrap();
let result = scan_all_clients_with_scanner_settings(
home.to_str().unwrap(),
&["codex".to_string()],
true,
&settings,
);
assert_eq!(result.get(ClientId::Codex).len(), 2);
}
#[test]
#[serial]
fn test_scan_all_clients_with_scanner_settings_respects_opencode_client_filter() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let data_dir = home.join(".local/share/opencode");
fs::create_dir_all(&data_dir).unwrap();
let auto_db = data_dir.join("opencode.db");
File::create(&auto_db).unwrap();
let outside_dir = home.join("elsewhere");
fs::create_dir_all(&outside_dir).unwrap();
let outside_db = outside_dir.join("opencode.db");
File::create(&outside_db).unwrap();
let settings = ScannerSettings {
opencode_db_paths: vec![outside_db.clone()],
..Default::default()
};
let scan = |clients: &[&str]| {
let owned: Vec<String> = clients.iter().map(|s| s.to_string()).collect();
scan_all_clients_with_scanner_settings(home.to_str().unwrap(), &owned, false, &settings)
};
let claude_only = scan(&["claude"]);
assert!(
claude_only.opencode_dbs.is_empty(),
"scanner.opencodeDbPaths must NOT leak into a Claude-only scan, \
got {:?}",
claude_only.opencode_dbs
);
let opencode_only = scan(&["opencode"]);
assert!(
opencode_only.opencode_dbs.iter().any(|p| p == &auto_db),
"expected auto-discovered {} in {:?}",
auto_db.display(),
opencode_only.opencode_dbs
);
assert!(
opencode_only.opencode_dbs.iter().any(|p| p == &outside_db),
"expected user-configured {} in {:?}",
outside_db.display(),
opencode_only.opencode_dbs
);
let synthetic_only = scan(&["synthetic"]);
assert!(
synthetic_only.opencode_dbs.iter().any(|p| p == &auto_db),
"synthetic-only filter must enable OpenCode auto-discovery, got {:?}",
synthetic_only.opencode_dbs
);
assert!(
synthetic_only.opencode_dbs.iter().any(|p| p == &outside_db),
"synthetic-only filter must merge user-configured paths, got {:?}",
synthetic_only.opencode_dbs
);
let all_clients = scan(&[]);
assert!(
all_clients.opencode_dbs.iter().any(|p| p == &auto_db),
"empty client filter must enable OpenCode auto-discovery, got {:?}",
all_clients.opencode_dbs
);
assert!(
all_clients.opencode_dbs.iter().any(|p| p == &outside_db),
"empty client filter must merge user-configured paths, got {:?}",
all_clients.opencode_dbs
);
}
#[test]
#[serial]
fn test_scan_all_clients_opencode_picks_up_channel_suffixed_dbs() {
let previous_xdg = std::env::var("XDG_DATA_HOME").ok();
let dir = TempDir::new().unwrap();
let home = dir.path();
let data_dir = home.join(".local/share/opencode");
fs::create_dir_all(&data_dir).unwrap();
File::create(data_dir.join("opencode.db")).unwrap();
File::create(data_dir.join("opencode-stable.db")).unwrap();
File::create(data_dir.join("opencode-nightly.db")).unwrap();
File::create(data_dir.join("opencode.db-wal")).unwrap();
File::create(data_dir.join("opencode-stable.db-shm")).unwrap();
unsafe { std::env::set_var("XDG_DATA_HOME", home.join(".local/share")) };
let result = scan_without_extra_dirs(home.to_str().unwrap(), &["opencode".to_string()]);
let names: Vec<String> = result
.opencode_dbs
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert_eq!(
names,
vec![
"opencode-nightly.db".to_string(),
"opencode-stable.db".to_string(),
"opencode.db".to_string(),
],
"expected all channel dbs, got {names:?}"
);
restore_env("XDG_DATA_HOME", previous_xdg);
}
#[test]
fn test_scan_all_clients_pi() {
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_pi_dir(home);
let result =
scan_all_clients_with_env_strategy(home.to_str().unwrap(), &["pi".to_string()], false);
assert_eq!(result.get(ClientId::Pi).len(), 1);
assert!(result.get(ClientId::OpenCode).is_empty());
assert!(result.get(ClientId::Claude).is_empty());
}
#[test]
fn test_scan_all_clients_omp_scanned_as_omp() {
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_omp_dir(home);
let result =
scan_all_clients_with_env_strategy(home.to_str().unwrap(), &["omp".to_string()], false);
assert_eq!(result.get(ClientId::Omp).len(), 1);
assert!(result.get(ClientId::Omp)[0].ends_with("2026-04-06T03-04-28Z_omp_ses_001.jsonl"));
assert!(result.get(ClientId::Pi).is_empty());
assert!(result.get(ClientId::OpenCode).is_empty());
}
#[test]
fn test_scan_all_clients_pi_and_omp_are_separate_clients() {
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_pi_dir(home);
setup_mock_omp_dir(home);
let result = scan_all_clients_with_env_strategy(
home.to_str().unwrap(),
&["pi".to_string(), "omp".to_string()],
false,
);
assert_eq!(result.get(ClientId::Pi).len(), 1);
assert_eq!(result.get(ClientId::Omp).len(), 1);
}
#[test]
#[serial]
fn test_scan_all_clients_zed_xdg_db() {
let previous_xdg = std::env::var("XDG_DATA_HOME").ok();
let dir = TempDir::new().unwrap();
let home = dir.path();
let zed_db = setup_mock_zed_xdg_db(home);
unsafe { std::env::set_var("XDG_DATA_HOME", home.join(".local/share")) };
let result = scan_without_extra_dirs(home.to_str().unwrap(), &["zed".to_string()]);
assert_eq!(result.zed_db.as_ref(), Some(&zed_db));
restore_env("XDG_DATA_HOME", previous_xdg);
}
#[cfg(target_os = "macos")]
#[test]
#[serial]
fn test_scan_all_clients_zed_macos_fallback() {
let previous_xdg = std::env::var("XDG_DATA_HOME").ok();
let dir = TempDir::new().unwrap();
let home = dir.path();
let zed_db = setup_mock_zed_macos_db(home);
unsafe { std::env::remove_var("XDG_DATA_HOME") };
let result = scan_without_extra_dirs(home.to_str().unwrap(), &["zed".to_string()]);
assert_eq!(result.zed_db.as_ref(), Some(&zed_db));
restore_env("XDG_DATA_HOME", previous_xdg);
}
#[test]
fn test_scan_all_clients_claude() {
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_claude_dir(home);
let result = scan_all_clients_with_env_strategy(
home.to_str().unwrap(),
&["claude".to_string()],
false,
);
assert_eq!(result.get(ClientId::Claude).len(), 1);
assert!(result.get(ClientId::OpenCode).is_empty());
}
#[test]
fn test_scan_all_clients_claude_nested_workflow_agents() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let wf = home.join(".claude/projects/myproject/sess-uuid/subagents/workflows/wf_abc");
fs::create_dir_all(&wf).unwrap();
let agent = wf.join("agent-a123.jsonl");
File::create(&agent).unwrap().write_all(b"{}\n").unwrap();
File::create(wf.join("journal.jsonl"))
.unwrap()
.write_all(b"{}\n")
.unwrap();
let result = scan_all_clients_with_env_strategy(
home.to_str().unwrap(),
&["claude".to_string()],
false,
);
assert!(
result.get(ClientId::Claude).iter().any(|p| p == &agent),
"nested workflow agent transcript must be discovered, got {:?}",
result.get(ClientId::Claude)
);
}
#[test]
fn test_scan_all_clients_claude_transcripts() {
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_claude_dir(home);
let transcript = setup_mock_claude_transcripts_dir(home);
let result = scan_all_clients_with_env_strategy(
home.to_str().unwrap(),
&["claude".to_string()],
false,
);
assert_eq!(result.get(ClientId::Claude).len(), 2);
assert!(
result
.get(ClientId::Claude)
.iter()
.any(|path| path == &transcript),
"expected Claude transcript {} in {:?}",
transcript.display(),
result.get(ClientId::Claude)
);
assert!(result.get(ClientId::OpenCode).is_empty());
}
#[test]
fn test_scan_all_clients_claude_transcripts_without_projects_dir() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let transcript = setup_mock_claude_transcripts_dir(home);
let result = scan_all_clients_with_env_strategy(
home.to_str().unwrap(),
&["claude".to_string()],
false,
);
assert_eq!(result.get(ClientId::Claude), &vec![transcript]);
assert!(result.get(ClientId::OpenCode).is_empty());
}
#[test]
fn test_scan_all_clients_claude_discovers_cc_mirror_variant_projects() {
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_claude_dir(home);
let variant_dir = home.join(".cc-mirror/kimi-code");
let config_dir = variant_dir.join("config");
let project_dir = config_dir.join("projects/project-one");
fs::create_dir_all(&project_dir).unwrap();
let variant_file = variant_dir.join("variant.json");
fs::write(
&variant_file,
format!(
r#"{{"name":"kimi-code","provider":"kimi","configDir":"{}"}}"#,
config_dir.display()
),
)
.unwrap();
let variant_session = project_dir.join("variant-session.jsonl");
File::create(&variant_session).unwrap();
let result = scan_all_clients_with_env_strategy(
home.to_str().unwrap(),
&["claude".to_string()],
false,
);
assert_eq!(result.get(ClientId::Claude).len(), 2);
assert!(
result
.get(ClientId::Claude)
.iter()
.any(|path| path == &variant_session),
"expected cc-mirror session {} in {:?}",
variant_session.display(),
result.get(ClientId::Claude)
);
}
#[test]
fn test_scan_all_clients_claude_dedups_cc_mirror_config_dir_pointing_at_normal_claude() {
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_claude_dir(home);
let normal_claude_dir = home.join(".claude");
let variant_dir = home.join(".cc-mirror/plain-mirror");
fs::create_dir_all(&variant_dir).unwrap();
fs::write(
variant_dir.join("variant.json"),
format!(
r#"{{"name":"plain-mirror","provider":"mirror","configDir":"{}"}}"#,
normal_claude_dir.display()
),
)
.unwrap();
let result = scan_all_clients_with_env_strategy(
home.to_str().unwrap(),
&["claude".to_string()],
false,
);
assert_eq!(
result.get(ClientId::Claude).len(),
1,
"cc-mirror variants pointing at ~/.claude must not duplicate normal Claude files"
);
}
#[test]
fn test_scan_all_clients_gemini() {
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_gemini_dir(home);
let result = scan_all_clients_with_env_strategy(
home.to_str().unwrap(),
&["gemini".to_string()],
false,
);
assert_eq!(result.get(ClientId::Gemini).len(), 1);
assert!(result.get(ClientId::OpenCode).is_empty());
}
#[test]
fn test_scan_all_clients_gemini_jsonl_session() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let gemini_path = home.join(".gemini/tmp/123/chats");
fs::create_dir_all(&gemini_path).unwrap();
File::create(gemini_path.join("session-abc.jsonl")).unwrap();
let result = scan_all_clients_with_env_strategy(
home.to_str().unwrap(),
&["gemini".to_string()],
false,
);
assert_eq!(result.get(ClientId::Gemini).len(), 1);
assert!(result.get(ClientId::Gemini)[0].ends_with("session-abc.jsonl"));
}
#[test]
fn test_scan_all_clients_copilot() {
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_copilot_dir(home);
let result = scan_all_clients_with_env_strategy(
home.to_str().unwrap(),
&["copilot".to_string()],
false,
);
assert_eq!(result.get(ClientId::Copilot).len(), 1);
assert!(result.get(ClientId::Copilot)[0].ends_with("copilot.jsonl"));
}
#[test]
#[serial]
fn test_scan_all_clients_copilot_includes_explicit_exporter_file() {
let previous = std::env::var("COPILOT_OTEL_FILE_EXPORTER_PATH").ok();
let dir = TempDir::new().unwrap();
let home = dir.path();
let explicit_dir = home.join("otel-export");
fs::create_dir_all(&explicit_dir).unwrap();
let explicit_file = explicit_dir.join("copilot-explicit.jsonl");
File::create(&explicit_file).unwrap();
unsafe { std::env::set_var("COPILOT_OTEL_FILE_EXPORTER_PATH", &explicit_file) };
let result = scan_without_extra_dirs(home.to_str().unwrap(), &["copilot".to_string()]);
assert_eq!(result.get(ClientId::Copilot), &vec![explicit_file]);
restore_env("COPILOT_OTEL_FILE_EXPORTER_PATH", previous);
}
#[test]
fn test_scan_all_clients_openclaw_jsonl_only() {
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_openclaw_dir(home);
let result = scan_all_clients_with_env_strategy(
home.to_str().unwrap(),
&["openclaw".to_string()],
false,
);
assert_eq!(result.get(ClientId::OpenClaw).len(), 3);
assert!(result
.get(ClientId::OpenClaw)
.iter()
.any(|path| path.ends_with("session-abc.jsonl")));
assert!(result
.get(ClientId::OpenClaw)
.iter()
.any(|path| path.ends_with("session-deleted.jsonl.deleted.123")));
assert!(result
.get(ClientId::OpenClaw)
.iter()
.any(|path| path.ends_with("session-reset.jsonl.reset.456")));
}
fn setup_mock_openclaw_sqlite_agents(base: &std::path::Path) -> Vec<PathBuf> {
let agents = base.join(".openclaw/agents");
let main_db = agents.join("main/agent/openclaw-agent.sqlite");
let work_db = agents.join("work/agent/openclaw-agent.sqlite");
for db in [&main_db, &work_db] {
fs::create_dir_all(db.parent().unwrap()).unwrap();
File::create(db).unwrap();
}
File::create(agents.join("main/agent/openclaw-agent.sqlite-wal")).unwrap();
File::create(agents.join("main/agent/openclaw-agent.sqlite-shm")).unwrap();
File::create(
agents
.join("main/agent")
.join(crate::sessions::openclaw::OPENCLAW_INCOGNITO_AGENT_DB_FILENAME),
)
.unwrap();
fs::create_dir_all(agents.join("empty/agent")).unwrap();
fs::create_dir_all(agents.join("legacy-only/sessions")).unwrap();
File::create(agents.join("README.txt")).unwrap();
let codex_sessions = agents.join("main/agent/codex-home/sessions/2026/08/30");
fs::create_dir_all(&codex_sessions).unwrap();
File::create(
codex_sessions
.join("rollout-2026-08-30T10-00-00-0192f3a4-5b6c-7d8e-9f01-23456789abcd.jsonl"),
)
.unwrap();
File::create(agents.join("main/agent/codex-home/history.jsonl")).unwrap();
vec![main_db, work_db]
}
#[test]
fn test_scan_all_clients_discovers_openclaw_agent_sqlite_databases() {
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_openclaw_dir(home);
let expected = setup_mock_openclaw_sqlite_agents(home);
let result = scan_all_clients_with_env_strategy(
home.to_str().unwrap(),
&["openclaw".to_string()],
false,
);
assert_eq!(result.openclaw_dbs, expected);
assert_eq!(result.get(ClientId::OpenClaw).len(), 5);
assert!(result
.get(ClientId::OpenClaw)
.iter()
.all(|path| !path.to_string_lossy().contains("sqlite")));
assert!(result
.get(ClientId::OpenClaw)
.iter()
.any(|path| path.components().any(|c| c.as_os_str() == "codex-home")));
}
#[test]
fn test_scan_all_clients_openclaw_sqlite_only_discovered_when_openclaw_enabled() {
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_openclaw_sqlite_agents(home);
let result = scan_all_clients_with_env_strategy(
home.to_str().unwrap(),
&["claude".to_string()],
false,
);
assert!(result.openclaw_dbs.is_empty());
}
#[test]
fn test_scan_all_clients_discovers_openclaw_sqlite_under_legacy_and_extra_roots() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let default_dbs = setup_mock_openclaw_sqlite_agents(home);
let legacy_db = home.join(".moltbot/agents/main/agent/openclaw-agent.sqlite");
fs::create_dir_all(legacy_db.parent().unwrap()).unwrap();
File::create(&legacy_db).unwrap();
let imported_root = home.join("imports/imac/openclaw/agents");
let imported_db = imported_root.join("main/agent/openclaw-agent.sqlite");
fs::create_dir_all(imported_db.parent().unwrap()).unwrap();
File::create(&imported_db).unwrap();
let mut settings = ScannerSettings::default();
settings.extra_scan_paths.insert(
"openclaw".to_string(),
vec![
imported_root.clone(),
home.join(".openclaw/agents"),
],
);
let result = scan_all_clients_with_scanner_settings(
home.to_str().unwrap(),
&["openclaw".to_string()],
false,
&settings,
);
let mut expected = default_dbs;
expected.push(legacy_db);
expected.push(imported_db);
expected.sort_unstable();
assert_eq!(result.openclaw_dbs, expected);
}
#[test]
fn test_discover_openclaw_agent_dbs_missing_root_is_empty() {
let dir = TempDir::new().unwrap();
assert!(discover_openclaw_agent_dbs(&dir.path().join("nope")).is_empty());
let file_root = dir.path().join("agents");
File::create(&file_root).unwrap();
assert!(discover_openclaw_agent_dbs(&file_root).is_empty());
}
#[test]
fn test_scan_all_clients_openclaw_transcript_copies() {
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_openclaw_dir(home);
setup_mock_openclaw_transcript_copies(home);
let result = scan_all_clients_with_env_strategy(
home.to_str().unwrap(),
&["openclaw".to_string()],
false,
);
let names: Vec<String> = result
.get(ClientId::OpenClaw)
.iter()
.map(|path| path.file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert!(names.iter().any(|n| n.ends_with(".bak")), "{names:?}");
assert!(
names.iter().any(|n| n.contains(".jsonl.broken-")),
"{names:?}"
);
assert!(
names
.iter()
.any(|n| n.contains(".pre-doctor-json-shape-repair-")),
"a backup suffix mentioning json is still a transcript: {names:?}"
);
assert!(
!names.iter().any(|n| n.contains(".codex-app-server.json")),
"binding sidecars are JSON metadata, not transcripts: {names:?}"
);
assert_eq!(result.get(ClientId::OpenClaw).len(), 6);
}
#[test]
fn test_scan_all_clients_openclaw_request_scans_codex_roots_as_lookup() {
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_openclaw_dir(home);
let codex_sessions = home.join(".codex/sessions/2026/08/30");
fs::create_dir_all(&codex_sessions).unwrap();
File::create(
codex_sessions
.join("rollout-2026-08-30T10-00-00-0192f3a4-5b6c-7d8e-9f01-23456789abcd.jsonl"),
)
.unwrap();
let openclaw_only = scan_all_clients_with_env_strategy(
home.to_str().unwrap(),
&["openclaw".to_string()],
false,
);
assert_eq!(openclaw_only.get(ClientId::Codex).len(), 1);
let claude_only = scan_all_clients_with_env_strategy(
home.to_str().unwrap(),
&["claude".to_string()],
false,
);
assert!(claude_only.get(ClientId::Codex).is_empty());
}
#[test]
#[serial]
fn test_scan_all_clients_openclaw_codex_home_rollout_is_listed_under_openclaw_only() {
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_openclaw_dir(home);
let codex_home = home.join(".openclaw/agents/main/agent/codex-home");
let rollout = codex_home.join(
"sessions/2026/08/30/rollout-2026-08-30T10-00-00-0192f3a4-5b6c-7d8e-9f01-23456789abcd.jsonl",
);
fs::create_dir_all(rollout.parent().unwrap()).unwrap();
File::create(&rollout).unwrap();
let mut env = EnvGuard::capture(&["CODEX_HOME"]);
env.set("CODEX_HOME", &codex_home);
let both = scan_without_extra_dirs(
home.to_str().unwrap(),
&["codex".to_string(), "openclaw".to_string()],
);
assert!(
both.get(ClientId::Codex).is_empty(),
"{:?}",
both.get(ClientId::Codex)
);
assert!(both.get(ClientId::OpenClaw).contains(&rollout));
let codex_only = scan_without_extra_dirs(home.to_str().unwrap(), &["codex".to_string()]);
assert_eq!(codex_only.get(ClientId::Codex), &vec![rollout.clone()]);
}
#[test]
fn test_scan_all_clients_openclaw_deleted_transcript() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let openclaw_sessions = home.join(".openclaw/agents/main/sessions");
fs::create_dir_all(&openclaw_sessions).unwrap();
File::create(openclaw_sessions.join("session-archived.jsonl.deleted.1700000000000"))
.unwrap();
let result = scan_all_clients_with_env_strategy(
home.to_str().unwrap(),
&["openclaw".to_string()],
false,
);
assert_eq!(result.get(ClientId::OpenClaw).len(), 1);
assert!(result.get(ClientId::OpenClaw)[0]
.ends_with("session-archived.jsonl.deleted.1700000000000"));
}
#[test]
fn scan_openclaw_excludes_only_canonical_compaction_checkpoints() {
let dir = TempDir::new().unwrap();
let sessions = dir.path().join(".openclaw/agents/main/sessions");
fs::create_dir_all(&sessions).unwrap();
let checkpoint = "11111111-1111-4111-8111-111111111111";
let kept = [
"primary.jsonl",
"primary.checkpoint.not-a-uuid.jsonl",
"primary.checkpoint.11111111-1111-0111-8111-111111111111.jsonl",
"named-checkpoint-session.jsonl.deleted.legacy-timestamp",
"primary.checkpoint.not-a-uuid.jsonl.reset.legacy-timestamp.zst",
];
for name in kept {
fs::write(sessions.join(name), b"{}").unwrap();
}
for name in [
format!("primary.checkpoint.{checkpoint}.jsonl"),
format!("primary.checkpoint.{checkpoint}.jsonl.zst"),
format!("primary.checkpoint.{checkpoint}.jsonl.deleted.legacy-timestamp"),
format!("primary.checkpoint.{checkpoint}.jsonl.reset.legacy-timestamp.zst"),
] {
fs::write(sessions.join(name), b"{}").unwrap();
}
let scan = scan_all_clients_with_env_strategy(
dir.path().to_str().unwrap(),
&["openclaw".to_string()],
false,
);
let names: HashSet<_> = scan
.get(ClientId::OpenClaw)
.iter()
.filter_map(|path| path.file_name().and_then(|name| name.to_str()))
.collect();
assert_eq!(names, kept.into_iter().collect());
let checkpoint_only = dir.path().join(".openclaw/agents/checkpoint-only/sessions");
fs::create_dir_all(&checkpoint_only).unwrap();
fs::write(
checkpoint_only.join(format!("only.checkpoint.{checkpoint}.jsonl")),
b"{}",
)
.unwrap();
let scan = scan_directory(checkpoint_only.to_str().unwrap(), "*.jsonl*");
assert!(
scan.is_empty(),
"checkpoint-only roots must contribute no sources"
);
}
#[test]
fn scan_openclaw_compressed_transcripts_reaches_the_parser() {
let dir = TempDir::new().unwrap();
let sessions = dir.path().join(".openclaw/agents/main/sessions");
fs::create_dir_all(&sessions).unwrap();
let content = br#"{"type":"message","message":{"role":"assistant","provider":"anthropic","model":"claude-sonnet-4-6","usage":{"input":100,"output":50},"timestamp":1788566869012}}"#;
for name in [
"plain-archive.jsonl.zst",
"deleted.jsonl.deleted.timestamp.nonce.zst",
"reset.jsonl.reset.timestamp.nonce.zst",
] {
fs::write(
sessions.join(name),
zstd::encode_all(&content[..], 0).unwrap(),
)
.unwrap();
}
fs::write(sessions.join("unrelated.zst"), b"not a session").unwrap();
let scan = scan_all_clients_with_env_strategy(
dir.path().to_str().unwrap(),
&["openclaw".to_string()],
false,
);
assert_eq!(scan.get(ClientId::OpenClaw).len(), 3);
for path in scan.get(ClientId::OpenClaw) {
let messages = crate::sessions::openclaw::parse_openclaw_transcript(path);
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].tokens.total(), 150);
}
}
#[test]
fn test_scan_all_clients_multiple() {
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_claude_dir(home);
setup_mock_gemini_dir(home);
let result = scan_all_clients_with_env_strategy(
home.to_str().unwrap(),
&["claude".to_string(), "gemini".to_string()],
false,
);
assert_eq!(result.get(ClientId::Claude).len(), 1);
assert_eq!(result.get(ClientId::Gemini).len(), 1);
assert!(result.get(ClientId::OpenCode).is_empty());
assert!(result.get(ClientId::Codex).is_empty());
}
#[test]
fn test_scan_all_clients_kiro_includes_cli_and_global_storage() {
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_kiro_dir(home);
setup_mock_kiro_global_storage_dir(home);
let result = scan_all_clients_with_env_strategy(
home.to_str().unwrap(),
&["kiro".to_string()],
false,
);
assert_eq!(result.get(ClientId::Kiro).len(), 4);
assert!(result
.get(ClientId::Kiro)
.iter()
.any(|p| p.ends_with("session-001.json")));
assert!(result
.get(ClientId::Kiro)
.iter()
.any(|p| p.ends_with("execution.chat")));
assert!(result
.get(ClientId::Kiro)
.iter()
.any(|p| p.ends_with("execution")));
}
#[test]
fn test_scan_all_clients_kiro_includes_ide_sessions() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let sess_dir = home.join(".kiro/sessions/workspace-a/sess_02f1c107");
fs::create_dir_all(&sess_dir).unwrap();
File::create(sess_dir.join("session.json")).unwrap();
File::create(sess_dir.join("messages.jsonl")).unwrap();
let result = scan_all_clients_with_env_strategy(
home.to_str().unwrap(),
&["kiro".to_string()],
false,
);
assert!(result
.get(ClientId::Kiro)
.iter()
.any(|p| p.ends_with("sess_02f1c107/session.json")));
assert!(!result
.get(ClientId::Kiro)
.iter()
.any(|p| p.ends_with("messages.jsonl")));
}
#[test]
fn test_scan_crush_registry_resolves_relative_and_absolute_data_dirs() {
let dir = TempDir::new().unwrap();
let project_a = dir.path().join("project-a");
let project_b_data = dir.path().join("project-b-data");
fs::create_dir_all(project_a.join(".crush")).unwrap();
fs::create_dir_all(&project_b_data).unwrap();
File::create(project_a.join(".crush").join("crush.db")).unwrap();
File::create(project_b_data.join("crush.db")).unwrap();
let registry_path = dir.path().join("projects.json");
let projects_json = format!(
r#"{{
"projects": [
{{ "path": "{}", "data_dir": ".crush" }},
{{ "path": "{}", "data_dir": "{}" }},
{{ "path": "{}", "data_dir": ".crush" }}
]
}}"#,
project_a.display(),
dir.path().join("project-b").display(),
project_b_data.display(),
dir.path().join("missing-project").display(),
);
setup_mock_crush_registry(®istry_path, &projects_json);
let result = scan_crush_registry(®istry_path);
assert_eq!(
result,
vec![
CrushDbSource {
db_path: project_a.join(".crush").join("crush.db"),
workspace_key: Some(project_a.display().to_string()),
workspace_label: Some("project-a".to_string()),
},
CrushDbSource {
db_path: project_b_data.join("crush.db"),
workspace_key: Some(dir.path().join("project-b").display().to_string()),
workspace_label: Some("project-b".to_string()),
},
]
);
}
#[test]
fn test_scan_crush_registry_skips_malformed_project_entries() {
let dir = TempDir::new().unwrap();
let valid_project = dir.path().join("valid-project");
fs::create_dir_all(valid_project.join(".crush")).unwrap();
File::create(valid_project.join(".crush").join("crush.db")).unwrap();
let registry_path = dir.path().join("projects.json");
let projects_json = format!(
r#"{{
"projects": [
{{ "path": "{}", "data_dir": ".crush" }},
{{ "path": 123, "data_dir": ".crush" }},
{{ "data_dir": ".crush" }},
"not-an-object"
]
}}"#,
valid_project.display()
);
setup_mock_crush_registry(®istry_path, &projects_json);
let result = scan_crush_registry(®istry_path);
assert_eq!(
result,
vec![CrushDbSource {
db_path: valid_project.join(".crush").join("crush.db"),
workspace_key: Some(valid_project.display().to_string()),
workspace_label: Some("valid-project".to_string()),
}]
);
}
#[test]
#[serial]
fn test_discover_crush_dbs_ignores_cwd_without_override() {
let previous_xdg = std::env::var("XDG_DATA_HOME").ok();
let previous_dir = std::env::current_dir().unwrap();
let dir = TempDir::new().unwrap();
let home = dir.path().join("home");
let project = dir.path().join("workspace");
let nested = project.join("src/subdir");
let xdg = dir.path().join("xdg");
fs::create_dir_all(&nested).unwrap();
fs::create_dir_all(xdg.join("crush")).unwrap();
fs::create_dir_all(project.join(".crush")).unwrap();
File::create(project.join(".crush").join("crush.db")).unwrap();
fs::write(
xdg.join("crush").join("projects.json"),
r#"{"projects":[]}"#,
)
.unwrap();
unsafe { std::env::set_var("XDG_DATA_HOME", &xdg) };
std::env::set_current_dir(&nested).unwrap();
let result = discover_crush_dbs(home.to_str().unwrap(), false);
assert!(result.is_empty());
restore_current_dir(&previous_dir);
restore_env("XDG_DATA_HOME", previous_xdg);
}
#[test]
#[serial]
fn test_discover_crush_dbs_honors_crush_global_data_env() {
let previous_global = std::env::var("CRUSH_GLOBAL_DATA").ok();
let previous_xdg = std::env::var("XDG_DATA_HOME").ok();
let previous_local_app_data = std::env::var("LOCALAPPDATA").ok();
unsafe { std::env::remove_var("LOCALAPPDATA") };
let dir = TempDir::new().unwrap();
let home = dir.path().join("home");
let global_data = dir.path().join("crush-global");
let project = dir.path().join("project");
fs::create_dir_all(project.join(".crush")).unwrap();
File::create(project.join(".crush").join("crush.db")).unwrap();
let projects_json = format!(
r#"{{ "projects": [ {{ "path": "{}", "data_dir": ".crush" }} ] }}"#,
project.display()
);
setup_mock_crush_registry(&global_data.join("projects.json"), &projects_json);
unsafe { std::env::set_var("CRUSH_GLOBAL_DATA", &global_data) };
unsafe { std::env::remove_var("XDG_DATA_HOME") };
let result = discover_crush_dbs(home.to_str().unwrap(), true);
assert_eq!(result.len(), 1);
assert_eq!(result[0].db_path, project.join(".crush").join("crush.db"));
let without_env_roots = discover_crush_dbs(home.to_str().unwrap(), false);
assert!(
without_env_roots.is_empty(),
"CRUSH_GLOBAL_DATA must be ignored when env roots are disabled"
);
restore_env("CRUSH_GLOBAL_DATA", previous_global);
restore_env("XDG_DATA_HOME", previous_xdg);
restore_env("LOCALAPPDATA", previous_local_app_data);
}
#[test]
#[serial]
fn test_discover_crush_dbs_scans_windows_local_appdata_under_home() {
let previous_global = std::env::var("CRUSH_GLOBAL_DATA").ok();
let previous_xdg = std::env::var("XDG_DATA_HOME").ok();
unsafe { std::env::remove_var("CRUSH_GLOBAL_DATA") };
unsafe { std::env::remove_var("XDG_DATA_HOME") };
let dir = TempDir::new().unwrap();
let home = dir.path().join("home");
let project = dir.path().join("project");
fs::create_dir_all(project.join(".crush")).unwrap();
File::create(project.join(".crush").join("crush.db")).unwrap();
let projects_json = format!(
r#"{{ "projects": [ {{ "path": "{}", "data_dir": ".crush" }} ] }}"#,
project.display()
);
setup_mock_crush_registry(
&home.join("AppData/Local/crush/projects.json"),
&projects_json,
);
let result = discover_crush_dbs(home.to_str().unwrap(), false);
assert_eq!(result.len(), 1);
assert_eq!(result[0].db_path, project.join(".crush").join("crush.db"));
restore_env("CRUSH_GLOBAL_DATA", previous_global);
restore_env("XDG_DATA_HOME", previous_xdg);
}
#[test]
#[serial]
fn test_discover_crush_dbs_dedups_across_registry_candidates() {
let previous_global = std::env::var("CRUSH_GLOBAL_DATA").ok();
let previous_xdg = std::env::var("XDG_DATA_HOME").ok();
let previous_local_app_data = std::env::var("LOCALAPPDATA").ok();
unsafe { std::env::remove_var("LOCALAPPDATA") };
let dir = TempDir::new().unwrap();
let home = dir.path().join("home");
let xdg = dir.path().join("xdg");
let project = dir.path().join("project");
fs::create_dir_all(project.join(".crush")).unwrap();
File::create(project.join(".crush").join("crush.db")).unwrap();
let projects_json = format!(
r#"{{ "projects": [ {{ "path": "{}", "data_dir": ".crush" }} ] }}"#,
project.display()
);
setup_mock_crush_registry(&xdg.join("crush/projects.json"), &projects_json);
setup_mock_crush_registry(
&home.join("AppData/Local/crush/projects.json"),
&projects_json,
);
unsafe { std::env::remove_var("CRUSH_GLOBAL_DATA") };
unsafe { std::env::set_var("XDG_DATA_HOME", &xdg) };
let result = discover_crush_dbs(home.to_str().unwrap(), true);
assert_eq!(
result.len(),
1,
"same crush.db reachable via multiple registries must be deduplicated"
);
restore_env("CRUSH_GLOBAL_DATA", previous_global);
restore_env("XDG_DATA_HOME", previous_xdg);
restore_env("LOCALAPPDATA", previous_local_app_data);
}
#[test]
#[serial]
fn test_scan_all_clients_crush_populates_crush_db_paths() {
let mut env = EnvGuard::capture(&["XDG_DATA_HOME", "CRUSH_GLOBAL_DATA", "LOCALAPPDATA"]);
env.remove("CRUSH_GLOBAL_DATA");
env.remove("LOCALAPPDATA");
let dir = TempDir::new().unwrap();
let home = dir.path().join("home");
let xdg = dir.path().join("xdg");
let project = dir.path().join("project");
let data_dir = project.join(".crush");
fs::create_dir_all(xdg.join("crush")).unwrap();
fs::create_dir_all(&data_dir).unwrap();
File::create(data_dir.join("crush.db")).unwrap();
let registry_path = xdg.join("crush").join("projects.json");
let projects_json = format!(
r#"{{
"projects": [
{{ "path": "{}", "data_dir": ".crush" }}
]
}}"#,
project.display()
);
setup_mock_crush_registry(®istry_path, &projects_json);
env.set("XDG_DATA_HOME", &xdg);
let result = scan_without_extra_dirs(home.to_str().unwrap(), &["crush".to_string()]);
assert_eq!(
result.crush_dbs,
vec![CrushDbSource {
db_path: data_dir.join("crush.db"),
workspace_key: Some(project.display().to_string()),
workspace_label: Some("project".to_string()),
}]
);
assert!(result.get(ClientId::Crush).is_empty());
}
#[test]
#[serial]
fn test_scan_all_clients_headless_paths() {
let mut env = EnvGuard::capture(&[
"TOKMESH_HEADLESS_DIR",
"TOKMESH_EXTRA_DIRS",
"CODEX_HOME",
"GEMINI_CLI_HOME",
]);
env.remove("TOKMESH_HEADLESS_DIR");
env.remove("TOKMESH_EXTRA_DIRS");
env.remove("CODEX_HOME");
env.remove("GEMINI_CLI_HOME");
let dir = TempDir::new().unwrap();
let home = dir.path();
let headless_root = home.join(".config").join("tokmesh").join("headless");
fs::create_dir_all(headless_root.join("codex")).unwrap();
File::create(headless_root.join("codex").join("codex.jsonl")).unwrap();
let result = scan_all_clients(
home.to_str().unwrap(),
&[
"claude".to_string(),
"codex".to_string(),
"gemini".to_string(),
],
);
assert!(result.get(ClientId::Claude).is_empty());
assert_eq!(result.get(ClientId::Codex).len(), 1);
assert!(result.get(ClientId::Gemini).is_empty());
}
#[test]
#[serial]
fn test_scan_all_clients_codex_with_env() {
let previous_codex = std::env::var("CODEX_HOME").ok();
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_codex_dir(home);
unsafe { std::env::set_var("CODEX_HOME", home.join(".codex")) };
let result = scan_without_extra_dirs(home.to_str().unwrap(), &["codex".to_string()]);
assert_eq!(result.get(ClientId::Codex).len(), 1);
restore_env("CODEX_HOME", previous_codex);
}
#[test]
#[serial]
fn test_scan_all_clients_codex_home_override_ignores_codex_home_env() {
let previous_codex = std::env::var("CODEX_HOME").ok();
let dir = TempDir::new().unwrap();
let home = dir.path().join("target-home");
let conflicting = dir.path().join("conflicting-codex-home");
setup_mock_codex_dir(&home);
fs::create_dir_all(&conflicting).unwrap();
unsafe { std::env::set_var("CODEX_HOME", &conflicting) };
let result = scan_all_clients_with_env_strategy(
home.to_str().unwrap(),
&["codex".to_string()],
false,
);
assert_eq!(result.get(ClientId::Codex).len(), 1);
assert!(result.get(ClientId::Codex)[0].ends_with("session.jsonl"));
assert!(result.get(ClientId::Codex)[0].starts_with(home.join(".codex")));
restore_env("CODEX_HOME", previous_codex);
}
#[test]
#[serial]
fn test_scan_all_clients_codex_archived_sessions() {
let previous_codex = std::env::var("CODEX_HOME").ok();
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_codex_archived_dir(home);
unsafe { std::env::set_var("CODEX_HOME", home.join(".codex")) };
let result = scan_without_extra_dirs(home.to_str().unwrap(), &["codex".to_string()]);
assert_eq!(result.get(ClientId::Codex).len(), 1);
assert!(result.get(ClientId::Codex)[0].ends_with("archived.jsonl"));
restore_env("CODEX_HOME", previous_codex);
}
#[test]
#[serial]
fn test_scan_all_clients_codex_sessions_and_archived() {
let previous_codex = std::env::var("CODEX_HOME").ok();
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_codex_dir(home);
setup_mock_codex_archived_dir(home);
unsafe { std::env::set_var("CODEX_HOME", home.join(".codex")) };
let result = scan_without_extra_dirs(home.to_str().unwrap(), &["codex".to_string()]);
assert_eq!(result.get(ClientId::Codex).len(), 2);
restore_env("CODEX_HOME", previous_codex);
}
#[test]
fn test_scan_all_clients_kimi() {
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_kimi_dir(home);
let result = scan_all_clients_with_env_strategy(
home.to_str().unwrap(),
&["kimi".to_string()],
false,
);
assert_eq!(result.get(ClientId::Kimi).len(), 1);
assert!(result.get(ClientId::Kimi)[0].ends_with("wire.jsonl"));
assert!(result.get(ClientId::OpenCode).is_empty());
assert!(result.get(ClientId::Claude).is_empty());
}
#[test]
#[cfg(target_os = "linux")]
fn test_scan_all_clients_kimi_work_is_not_discovered_on_linux() {
let dir = TempDir::new().unwrap();
let wire = dir.path().join(
"Library/Application Support/kimi-desktop/daimon-share/daimon/runtime/kimi-code/home/sessions/wd_workspace_c107cac82a87/conv-linux-session/agents/main/wire.jsonl",
);
fs::create_dir_all(wire.parent().unwrap()).unwrap();
File::create(&wire).unwrap();
let result = scan_all_clients_with_env_strategy(
dir.path().to_str().unwrap(),
&["kimi".to_string()],
false,
);
assert!(result.get(ClientId::Kimi).is_empty());
}
#[test]
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn test_scan_all_clients_kimi_work_discovers_conv_and_ctitle_sessions() {
let dir = TempDir::new().unwrap();
let home = dir.path();
let conv = kimi_work_test_wire(home, "conv-4e171339d10b9954d0fc24da");
let ctitle = kimi_work_test_wire(home, "ctitle-01a01fe5-e170-765c-a1b3-c4daad0cda13");
let wire = concat!(
"{\"type\":\"llm.request\",\"model\":\"k2d6-agent\",\"time\":1780319377000}\n",
"{\"type\":\"usage.record\",\"model\":\"k2d6-agent\",\"usage\":{\"inputOther\":100,\"output\":10,\"inputCacheRead\":0,\"inputCacheCreation\":0},\"usageScope\":\"turn\",\"time\":1780319377010}\n"
);
for path in [&conv, &ctitle] {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, wire).unwrap();
}
let result = scan_all_clients_with_env_strategy(
home.to_str().unwrap(),
&["kimi".to_string()],
false,
);
let kimi_files = result.get(ClientId::Kimi);
assert_eq!(kimi_files.len(), 2);
assert!(kimi_files.contains(&conv));
assert!(kimi_files.contains(&ctitle));
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn kimi_work_test_wire(home: &std::path::Path, session_id: &str) -> PathBuf {
let work_root = if cfg!(target_os = "macos") {
home.join("Library").join("Application Support")
} else {
home.join("AppData").join("Roaming")
};
kimi_work_wire_from_root(&work_root, session_id)
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn kimi_work_wire_from_root(root: &std::path::Path, session_id: &str) -> PathBuf {
root.join("kimi-desktop")
.join("daimon-share")
.join("daimon")
.join("runtime")
.join("kimi-code")
.join("home")
.join("sessions")
.join("wd_workspace_c107cac82a87")
.join(session_id)
.join("agents")
.join("main")
.join("wire.jsonl")
}
#[test]
fn test_scan_all_clients_grok() {
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_grok_dir(home);
let result = scan_all_clients_with_env_strategy(
home.to_str().unwrap(),
&["grok".to_string()],
false,
);
assert_eq!(result.get(ClientId::Grok).len(), 1);
assert!(result.get(ClientId::Grok)[0].ends_with("updates.jsonl"));
assert!(result.get(ClientId::OpenCode).is_empty());
assert!(result.get(ClientId::Claude).is_empty());
}
#[test]
fn test_scan_all_clients_jcode() {
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_jcode_dir(home);
let result = scan_all_clients_with_env_strategy(
home.to_str().unwrap(),
&["jcode".to_string()],
false,
);
assert_eq!(result.get(ClientId::Jcode).len(), 1);
assert!(result.get(ClientId::Jcode)[0].ends_with("session_fixture.json"));
assert!(result.get(ClientId::OpenCode).is_empty());
assert!(result.get(ClientId::Claude).is_empty());
}
#[test]
fn test_scan_all_clients_roocode() {
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_roocode_dir(home);
let result = scan_all_clients_with_env_strategy(
home.to_str().unwrap(),
&["roocode".to_string()],
false,
);
assert_eq!(result.get(ClientId::RooCode).len(), 2);
assert!(result
.get(ClientId::RooCode)
.iter()
.all(|p| p.ends_with("ui_messages.json")));
}
#[test]
fn test_scan_all_clients_kilocode() {
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_kilocode_dir(home);
let result = scan_all_clients_with_env_strategy(
home.to_str().unwrap(),
&["kilocode".to_string()],
false,
);
assert_eq!(result.get(ClientId::KiloCode).len(), 2);
assert!(result
.get(ClientId::KiloCode)
.iter()
.all(|p| p.ends_with("ui_messages.json")));
}
#[test]
fn test_scan_all_clients_cline() {
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_cline_dir(home);
let result = scan_all_clients_with_env_strategy(
home.to_str().unwrap(),
&["cline".to_string()],
false,
);
assert_eq!(result.get(ClientId::Cline).len(), 4);
assert!(result
.get(ClientId::Cline)
.iter()
.all(|p| p.ends_with("ui_messages.json")));
}
#[test]
fn test_parse_extra_dirs_basic() {
let enabled: HashSet<ClientId> = [ClientId::Claude, ClientId::OpenClaw]
.iter()
.copied()
.collect();
let dirs = parse_extra_dirs("claude:/tmp/mac-sessions,openclaw:/tmp/oc-extra", &enabled);
assert_eq!(dirs.len(), 2);
assert_eq!(dirs[0].0, ClientId::Claude);
assert_eq!(dirs[0].1, "/tmp/mac-sessions");
assert_eq!(dirs[1].0, ClientId::OpenClaw);
assert_eq!(dirs[1].1, "/tmp/oc-extra");
}
#[test]
fn test_parse_extra_dirs_filters_disabled_clients() {
let enabled: HashSet<ClientId> = [ClientId::Claude].iter().copied().collect();
let dirs = parse_extra_dirs(
"claude:/tmp/mac-sessions,gemini:/tmp/gemini-extra",
&enabled,
);
assert_eq!(dirs.len(), 1);
assert_eq!(dirs[0].0, ClientId::Claude);
}
#[test]
fn test_parse_extra_dirs_skips_unsupported_clients() {
let enabled: HashSet<ClientId> =
[ClientId::Claude, ClientId::Kilo].iter().copied().collect();
let dirs = parse_extra_dirs("claude:/tmp/mac-sessions,kilo:/tmp/kilo", &enabled);
assert_eq!(dirs.len(), 1);
assert_eq!(dirs[0].0, ClientId::Claude);
assert_eq!(dirs[0].1, "/tmp/mac-sessions");
}
#[test]
fn test_parse_extra_dirs_empty_string() {
let enabled: HashSet<ClientId> = ClientId::iter().collect();
let dirs = parse_extra_dirs("", &enabled);
assert!(dirs.is_empty());
}
#[test]
fn test_parse_extra_dirs_invalid_client() {
let enabled: HashSet<ClientId> = ClientId::iter().collect();
let dirs = parse_extra_dirs("nonexistent:/tmp/foo", &enabled);
assert!(dirs.is_empty());
}
#[test]
#[serial]
fn test_scan_all_clients_with_extra_dirs() {
let previous = std::env::var("TOKMESH_EXTRA_DIRS").ok();
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_claude_dir(home);
let extra_dir = TempDir::new().unwrap();
let extra_project = extra_dir.path().join("mac-project");
fs::create_dir_all(&extra_project).unwrap();
File::create(extra_project.join("extra-session.jsonl")).unwrap();
unsafe {
std::env::set_var(
"TOKMESH_EXTRA_DIRS",
format!("claude:{}", extra_dir.path().to_string_lossy()),
)
};
let result = scan_all_clients(home.to_str().unwrap(), &["claude".to_string()]);
assert_eq!(result.get(ClientId::Claude).len(), 2);
restore_env("TOKMESH_EXTRA_DIRS", previous);
}
fn setup_mock_codebuff_chat(base: &Path, channel: &str, chat_id: &str) -> PathBuf {
let chat_dir = base
.join(".config")
.join(channel)
.join("projects")
.join("sandbox")
.join("chats")
.join(chat_id);
fs::create_dir_all(&chat_dir).unwrap();
let file_path = chat_dir.join("chat-messages.json");
let mut file = File::create(&file_path).unwrap();
writeln!(file, "[]").unwrap();
file_path
}
#[test]
#[serial]
fn test_scan_all_clients_codebuff_walks_all_three_channels_by_default() {
let previous = std::env::var("CODEBUFF_DATA_DIR").ok();
unsafe { std::env::remove_var("CODEBUFF_DATA_DIR") };
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_codebuff_chat(home, "manicode", "2025-12-14T10-00-00.000Z");
setup_mock_codebuff_chat(home, "manicode-dev", "2025-12-14T11-00-00.000Z");
setup_mock_codebuff_chat(home, "manicode-staging", "2025-12-14T12-00-00.000Z");
let result = scan_without_extra_dirs(home.to_str().unwrap(), &["codebuff".to_string()]);
assert_eq!(result.get(ClientId::Codebuff).len(), 3);
restore_env("CODEBUFF_DATA_DIR", previous);
}
#[test]
#[serial]
fn test_scan_all_clients_codebuff_empty_env_var_falls_back_to_default_channels() {
let previous = std::env::var("CODEBUFF_DATA_DIR").ok();
unsafe { std::env::set_var("CODEBUFF_DATA_DIR", " ") };
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_codebuff_chat(home, "manicode", "2025-12-14T10-00-00.000Z");
setup_mock_codebuff_chat(home, "manicode-dev", "2025-12-14T11-00-00.000Z");
let result = scan_without_extra_dirs(home.to_str().unwrap(), &["codebuff".to_string()]);
assert_eq!(result.get(ClientId::Codebuff).len(), 2);
restore_env("CODEBUFF_DATA_DIR", previous);
}
#[test]
#[serial]
fn test_scan_all_clients_codebuff_honours_explicit_env_override() {
let previous = std::env::var("CODEBUFF_DATA_DIR").ok();
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_codebuff_chat(home, "manicode", "2025-12-14T10-00-00.000Z");
let override_root = dir.path().join("custom-codebuff");
let override_chat_dir = override_root
.join("projects")
.join("sandbox")
.join("chats")
.join("2025-12-14T11-00-00.000Z");
fs::create_dir_all(&override_chat_dir).unwrap();
File::create(override_chat_dir.join("chat-messages.json")).unwrap();
unsafe {
std::env::set_var(
"CODEBUFF_DATA_DIR",
override_root.to_string_lossy().as_ref(),
)
};
let result = scan_without_extra_dirs(home.to_str().unwrap(), &["codebuff".to_string()]);
assert_eq!(result.get(ClientId::Codebuff).len(), 1);
assert!(result.get(ClientId::Codebuff)[0]
.to_string_lossy()
.contains("custom-codebuff"));
restore_env("CODEBUFF_DATA_DIR", previous);
}
#[test]
#[serial]
fn test_scan_all_clients_ignores_extra_dirs_when_env_roots_disabled() {
let previous = std::env::var("TOKMESH_EXTRA_DIRS").ok();
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_claude_dir(home);
let extra_dir = TempDir::new().unwrap();
let extra_project = extra_dir.path().join("mac-project");
fs::create_dir_all(&extra_project).unwrap();
File::create(extra_project.join("extra-session.jsonl")).unwrap();
unsafe {
std::env::set_var(
"TOKMESH_EXTRA_DIRS",
format!("claude:{}", extra_dir.path().to_string_lossy()),
)
};
let result = scan_all_clients_with_env_strategy(
home.to_str().unwrap(),
&["claude".to_string()],
false,
);
assert_eq!(result.get(ClientId::Claude).len(), 1);
restore_env("TOKMESH_EXTRA_DIRS", previous);
}
#[test]
#[serial]
fn test_extra_scan_path_outside_home_does_not_block_scan() {
let fake_home = TempDir::new().unwrap();
let outside_home = TempDir::new().unwrap();
let outside_path = outside_home.path();
assert!(!outside_path.starts_with(fake_home.path()));
let session_dir = outside_path.join("sessions");
fs::create_dir_all(&session_dir).unwrap();
File::create(session_dir.join("session-abc123.json")).unwrap();
let previous = std::env::var("TOKMESH_EXTRA_DIRS").ok();
unsafe {
std::env::set_var(
"TOKMESH_EXTRA_DIRS",
format!("claude:{}", outside_path.to_string_lossy()),
)
};
let _result = scan_all_clients_with_env_strategy(
fake_home.path().to_str().unwrap(),
&["claude".to_string()],
true, );
restore_env("TOKMESH_EXTRA_DIRS", previous);
}
fn setup_mock_gjc_session(home: &Path, slug: &str, name: &str) -> PathBuf {
let dir = home.join(".gjc/agent/sessions").join(slug);
fs::create_dir_all(&dir).unwrap();
let file_path = dir.join(name);
File::create(&file_path).unwrap();
file_path
}
#[test]
#[serial]
fn test_gjc_discovery_recursive_glob_depth1_and_depth2() {
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_gjc_session(home, "--work--proj--", "sess-001.jsonl");
let depth2 = home.join(".gjc/agent/sessions/--work--proj--/sess-001");
fs::create_dir_all(&depth2).unwrap();
File::create(depth2.join("0-Pass.jsonl")).unwrap();
let result =
scan_all_clients_with_env_strategy(home.to_str().unwrap(), &["gjc".to_string()], false);
assert_eq!(result.get(ClientId::Gjc).len(), 2);
}
#[test]
#[serial]
fn test_gjc_discovery_home_fallback_when_env_disabled() {
let previous = std::env::var("GJC_CODING_AGENT_DIR").ok();
let other = TempDir::new().unwrap();
unsafe {
std::env::set_var(
"GJC_CODING_AGENT_DIR",
other.path().to_string_lossy().as_ref(),
)
};
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_gjc_session(home, "slug", "a.jsonl");
let result =
scan_all_clients_with_env_strategy(home.to_str().unwrap(), &["gjc".to_string()], false);
assert_eq!(result.get(ClientId::Gjc).len(), 1);
restore_env("GJC_CODING_AGENT_DIR", previous);
}
#[test]
#[serial]
fn test_gjc_discovery_env_override() {
let mut env = EnvGuard::capture(&[
"GJC_CODING_AGENT_DIR",
"GJC_CONFIG_DIR",
"PI_CONFIG_DIR",
"XDG_DATA_HOME",
"TOKMESH_EXTRA_DIRS",
]);
env.remove("GJC_CONFIG_DIR");
env.remove("PI_CONFIG_DIR");
env.remove("XDG_DATA_HOME");
env.remove("TOKMESH_EXTRA_DIRS");
let dir = TempDir::new().unwrap();
let home = dir.path();
let agent_dir = dir.path().join("custom-gjc-agent");
let override_sessions = agent_dir.join("sessions").join("slug");
fs::create_dir_all(&override_sessions).unwrap();
File::create(override_sessions.join("o.jsonl")).unwrap();
env.set("GJC_CODING_AGENT_DIR", &agent_dir);
let result = scan_all_clients(home.to_str().unwrap(), &["gjc".to_string()]);
assert!(result
.get(ClientId::Gjc)
.iter()
.any(|p| p.to_string_lossy().contains("custom-gjc-agent")));
}
#[test]
#[serial]
fn test_gjc_discovery_multi_root_files_dedup_to_one() {
let mut env = EnvGuard::capture(&[
"GJC_CODING_AGENT_DIR",
"GJC_CONFIG_DIR",
"PI_CONFIG_DIR",
"XDG_DATA_HOME",
"TOKMESH_EXTRA_DIRS",
]);
env.remove("GJC_CONFIG_DIR");
env.remove("PI_CONFIG_DIR");
env.remove("XDG_DATA_HOME");
env.remove("TOKMESH_EXTRA_DIRS");
let dir = TempDir::new().unwrap();
let home = dir.path();
setup_mock_gjc_session(home, "slug", "dup.jsonl");
let agent_dir = home.join(".gjc/agent");
env.set("GJC_CODING_AGENT_DIR", &agent_dir);
let result = scan_all_clients(home.to_str().unwrap(), &["gjc".to_string()]);
assert_eq!(result.get(ClientId::Gjc).len(), 1);
}
#[test]
#[serial]
fn test_gjc_discovery_gjc_config_dir() {
let prev_agent = std::env::var("GJC_CODING_AGENT_DIR").ok();
let prev_config = std::env::var("GJC_CONFIG_DIR").ok();
let prev_pi = std::env::var("PI_CONFIG_DIR").ok();
let prev_xdg = std::env::var("XDG_DATA_HOME").ok();
unsafe {
std::env::remove_var("GJC_CODING_AGENT_DIR");
std::env::remove_var("PI_CONFIG_DIR");
std::env::remove_var("XDG_DATA_HOME");
}
let home_dir = TempDir::new().unwrap();
let config_dir = TempDir::new().unwrap();
let sessions = config_dir.path().join("agent/sessions/my-slug");
fs::create_dir_all(&sessions).unwrap();
File::create(sessions.join("x.jsonl")).unwrap();
unsafe {
std::env::set_var(
"GJC_CONFIG_DIR",
config_dir.path().to_string_lossy().as_ref(),
)
};
let result =
scan_without_extra_dirs(home_dir.path().to_str().unwrap(), &["gjc".to_string()]);
assert!(
!result.get(ClientId::Gjc).is_empty(),
"expected at least 1 file from GJC_CONFIG_DIR root, got {:?}",
result.get(ClientId::Gjc)
);
assert!(
result
.get(ClientId::Gjc)
.iter()
.any(|p| p.to_string_lossy().contains("my-slug")),
"discovered files should include the GJC_CONFIG_DIR session path"
);
restore_env("GJC_CODING_AGENT_DIR", prev_agent);
restore_env("GJC_CONFIG_DIR", prev_config);
restore_env("PI_CONFIG_DIR", prev_pi);
restore_env("XDG_DATA_HOME", prev_xdg);
}
#[test]
#[serial]
fn test_gjc_discovery_pi_config_dir() {
let prev_agent = std::env::var("GJC_CODING_AGENT_DIR").ok();
let prev_config = std::env::var("GJC_CONFIG_DIR").ok();
let prev_pi = std::env::var("PI_CONFIG_DIR").ok();
let prev_xdg = std::env::var("XDG_DATA_HOME").ok();
unsafe {
std::env::remove_var("GJC_CODING_AGENT_DIR");
std::env::remove_var("GJC_CONFIG_DIR");
std::env::remove_var("XDG_DATA_HOME");
}
let home_dir = TempDir::new().unwrap();
let pi_config = TempDir::new().unwrap();
let sessions = pi_config.path().join("agent/sessions/pi-slug");
fs::create_dir_all(&sessions).unwrap();
File::create(sessions.join("x.jsonl")).unwrap();
unsafe { std::env::set_var("PI_CONFIG_DIR", pi_config.path().to_string_lossy().as_ref()) };
let result =
scan_without_extra_dirs(home_dir.path().to_str().unwrap(), &["gjc".to_string()]);
assert!(
!result.get(ClientId::Gjc).is_empty(),
"expected at least 1 file from PI_CONFIG_DIR root, got {:?}",
result.get(ClientId::Gjc)
);
assert!(
result
.get(ClientId::Gjc)
.iter()
.any(|p| p.to_string_lossy().contains("pi-slug")),
"discovered files should include the PI_CONFIG_DIR session path"
);
restore_env("GJC_CODING_AGENT_DIR", prev_agent);
restore_env("GJC_CONFIG_DIR", prev_config);
restore_env("PI_CONFIG_DIR", prev_pi);
restore_env("XDG_DATA_HOME", prev_xdg);
}
#[test]
#[serial]
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn test_gjc_discovery_xdg_data_home_flattened() {
let prev_agent = std::env::var("GJC_CODING_AGENT_DIR").ok();
let prev_config = std::env::var("GJC_CONFIG_DIR").ok();
let prev_pi = std::env::var("PI_CONFIG_DIR").ok();
let prev_xdg = std::env::var("XDG_DATA_HOME").ok();
unsafe {
std::env::remove_var("GJC_CODING_AGENT_DIR");
std::env::remove_var("GJC_CONFIG_DIR");
std::env::remove_var("PI_CONFIG_DIR");
}
let home_dir = TempDir::new().unwrap();
let xdg_data = TempDir::new().unwrap();
let sessions = xdg_data.path().join("gjc/sessions/xdg-slug");
fs::create_dir_all(&sessions).unwrap();
File::create(sessions.join("x.jsonl")).unwrap();
unsafe { std::env::set_var("XDG_DATA_HOME", xdg_data.path().to_string_lossy().as_ref()) };
let result =
scan_without_extra_dirs(home_dir.path().to_str().unwrap(), &["gjc".to_string()]);
assert!(
!result.get(ClientId::Gjc).is_empty(),
"expected at least 1 file from XDG_DATA_HOME/gjc/sessions, got {:?}",
result.get(ClientId::Gjc)
);
assert!(
result
.get(ClientId::Gjc)
.iter()
.any(|p| p.to_string_lossy().contains("xdg-slug")),
"XDG redirect path must be discovered (flattened, no agent/ segment)"
);
restore_env("GJC_CODING_AGENT_DIR", prev_agent);
restore_env("GJC_CONFIG_DIR", prev_config);
restore_env("PI_CONFIG_DIR", prev_pi);
restore_env("XDG_DATA_HOME", prev_xdg);
}
#[test]
#[serial]
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn test_gjc_discovery_multi_root_home_and_xdg_both_counted() {
let prev_agent = std::env::var("GJC_CODING_AGENT_DIR").ok();
let prev_config = std::env::var("GJC_CONFIG_DIR").ok();
let prev_pi = std::env::var("PI_CONFIG_DIR").ok();
let prev_xdg = std::env::var("XDG_DATA_HOME").ok();
unsafe {
std::env::remove_var("GJC_CODING_AGENT_DIR");
std::env::remove_var("GJC_CONFIG_DIR");
std::env::remove_var("PI_CONFIG_DIR");
}
let home_dir = TempDir::new().unwrap();
let xdg_data = TempDir::new().unwrap();
setup_mock_gjc_session(home_dir.path(), "home-slug", "home.jsonl");
let xdg_sessions = xdg_data.path().join("gjc/sessions/xdg-slug");
fs::create_dir_all(&xdg_sessions).unwrap();
File::create(xdg_sessions.join("xdg.jsonl")).unwrap();
unsafe { std::env::set_var("XDG_DATA_HOME", xdg_data.path().to_string_lossy().as_ref()) };
let result =
scan_without_extra_dirs(home_dir.path().to_str().unwrap(), &["gjc".to_string()]);
assert_eq!(
result.get(ClientId::Gjc).len(),
2,
"both roots must contribute; files should NOT be collapsed to 1 (N4 push-all, not first-match). got {:?}",
result.get(ClientId::Gjc)
);
restore_env("GJC_CODING_AGENT_DIR", prev_agent);
restore_env("GJC_CONFIG_DIR", prev_config);
restore_env("PI_CONFIG_DIR", prev_pi);
restore_env("XDG_DATA_HOME", prev_xdg);
}
#[test]
#[serial]
fn test_gjc_discovery_use_env_roots_false_ignores_config_and_xdg() {
let prev_agent = std::env::var("GJC_CODING_AGENT_DIR").ok();
let prev_config = std::env::var("GJC_CONFIG_DIR").ok();
let prev_pi = std::env::var("PI_CONFIG_DIR").ok();
let prev_xdg = std::env::var("XDG_DATA_HOME").ok();
let home_dir = TempDir::new().unwrap();
let config_dir = TempDir::new().unwrap();
let xdg_data = TempDir::new().unwrap();
setup_mock_gjc_session(home_dir.path(), "home-slug", "home.jsonl");
let config_sessions = config_dir.path().join("agent/sessions/cfg-slug");
fs::create_dir_all(&config_sessions).unwrap();
File::create(config_sessions.join("cfg.jsonl")).unwrap();
let xdg_sessions = xdg_data.path().join("gjc/sessions/xdg-slug");
fs::create_dir_all(&xdg_sessions).unwrap();
File::create(xdg_sessions.join("xdg.jsonl")).unwrap();
unsafe {
std::env::remove_var("GJC_CODING_AGENT_DIR");
std::env::set_var(
"GJC_CONFIG_DIR",
config_dir.path().to_string_lossy().as_ref(),
);
std::env::set_var("XDG_DATA_HOME", xdg_data.path().to_string_lossy().as_ref());
}
let result = scan_all_clients_with_env_strategy(
home_dir.path().to_str().unwrap(),
&["gjc".to_string()],
false, );
assert_eq!(
result.get(ClientId::Gjc).len(),
1,
"use_env_roots=false must suppress GJC_CONFIG_DIR and XDG_DATA_HOME, yielding only the home fallback. got {:?}",
result.get(ClientId::Gjc)
);
assert!(
result
.get(ClientId::Gjc)
.iter()
.any(|p| p.to_string_lossy().contains("home-slug")),
"the sole discovered file must be from the home fallback"
);
restore_env("GJC_CODING_AGENT_DIR", prev_agent);
restore_env("GJC_CONFIG_DIR", prev_config);
restore_env("PI_CONFIG_DIR", prev_pi);
restore_env("XDG_DATA_HOME", prev_xdg);
}
#[test]
#[serial]
fn test_gjc_discovery_nonexistent_agent_dir_no_panic() {
let prev_agent = std::env::var("GJC_CODING_AGENT_DIR").ok();
let prev_config = std::env::var("GJC_CONFIG_DIR").ok();
let prev_pi = std::env::var("PI_CONFIG_DIR").ok();
let prev_xdg = std::env::var("XDG_DATA_HOME").ok();
let home_dir = TempDir::new().unwrap();
unsafe {
std::env::set_var(
"GJC_CODING_AGENT_DIR",
"/nonexistent/path/that/does/not/exist",
);
std::env::remove_var("GJC_CONFIG_DIR");
std::env::remove_var("PI_CONFIG_DIR");
std::env::remove_var("XDG_DATA_HOME");
}
setup_mock_gjc_session(home_dir.path(), "slug", "a.jsonl");
let result =
scan_without_extra_dirs(home_dir.path().to_str().unwrap(), &["gjc".to_string()]);
assert_eq!(
result.get(ClientId::Gjc).len(),
1,
"nonexistent GJC_CODING_AGENT_DIR should be silently skipped, home fallback must still be found. got {:?}",
result.get(ClientId::Gjc)
);
restore_env("GJC_CODING_AGENT_DIR", prev_agent);
restore_env("GJC_CONFIG_DIR", prev_config);
restore_env("PI_CONFIG_DIR", prev_pi);
restore_env("XDG_DATA_HOME", prev_xdg);
}
}