#![cfg(not(target_os = "emscripten"))]
use anyhow::Result;
use chrono::{DateTime, Utc};
use clap::{Args, ValueEnum};
use std::path::PathBuf;
use crate::cmd_export::RepoSpec;
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
#[value(rename_all = "lower")]
pub enum HarnessArg {
Claude,
Gemini,
Codex,
Opencode,
Pi,
}
#[derive(Args, Debug)]
pub struct ShareArgs {
#[arg(long)]
pub url: Option<String>,
#[arg(long, conflicts_with_all = ["repo", "public"])]
pub anon: bool,
#[arg(long, value_parser = crate::cmd_export::parse_repo_spec)]
pub repo: Option<RepoSpec>,
#[arg(long, alias = "slug")]
pub name: Option<String>,
#[arg(long)]
pub public: bool,
#[arg(long, value_enum)]
pub harness: Option<HarnessArg>,
#[arg(long, requires = "harness")]
pub session: Option<String>,
#[arg(long)]
pub project: Option<PathBuf>,
#[arg(long)]
pub no_cache: bool,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) enum Harness {
Claude,
Gemini,
Codex,
Opencode,
Pi,
}
impl Harness {
pub(crate) fn name(&self) -> &'static str {
match self {
Harness::Claude => "claude",
Harness::Gemini => "gemini",
Harness::Codex => "codex",
Harness::Opencode => "opencode",
Harness::Pi => "pi",
}
}
pub(crate) fn symbol(&self) -> &'static str {
match self {
Harness::Claude => "claude ",
Harness::Gemini => "gemini ",
Harness::Codex => "codex ",
Harness::Opencode => "opencode",
Harness::Pi => "pi ",
}
}
pub(crate) fn project_keyed(&self) -> bool {
matches!(self, Harness::Claude | Harness::Gemini | Harness::Pi)
}
pub(crate) fn from_arg(arg: HarnessArg) -> Self {
match arg {
HarnessArg::Claude => Harness::Claude,
HarnessArg::Gemini => Harness::Gemini,
HarnessArg::Codex => Harness::Codex,
HarnessArg::Opencode => Harness::Opencode,
HarnessArg::Pi => Harness::Pi,
}
}
pub(crate) fn parse(s: &str) -> Option<Self> {
match s {
"claude" => Some(Harness::Claude),
"gemini" => Some(Harness::Gemini),
"codex" => Some(Harness::Codex),
"opencode" => Some(Harness::Opencode),
"pi" => Some(Harness::Pi),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct SessionRow {
pub(crate) harness: Harness,
pub(crate) project: Option<String>,
pub(crate) cwd: Option<String>,
pub(crate) session_id: String,
pub(crate) title: String,
pub(crate) last_activity: Option<DateTime<Utc>>,
pub(crate) message_count: usize,
pub(crate) matches_cwd: bool,
}
#[derive(Default)]
pub(crate) struct HarnessBundle {
pub(crate) claude: Option<toolpath_claude::ClaudeConvo>,
pub(crate) gemini: Option<toolpath_gemini::GeminiConvo>,
pub(crate) codex: Option<toolpath_codex::CodexConvo>,
pub(crate) opencode: Option<toolpath_opencode::OpencodeConvo>,
pub(crate) pi: Option<toolpath_pi::PiConvo>,
}
impl HarnessBundle {
pub(crate) fn from_environment() -> Self {
Self {
claude: Some(toolpath_claude::ClaudeConvo::new()),
gemini: Some(toolpath_gemini::GeminiConvo::new()),
codex: Some(toolpath_codex::CodexConvo::new()),
opencode: Some(toolpath_opencode::OpencodeConvo::new()),
pi: Some(toolpath_pi::PiConvo::new()),
}
}
}
pub(crate) fn gather_sessions(
bundle: &HarnessBundle,
cwd: &std::path::Path,
harness_filter: Option<Harness>,
project_filter: Option<&std::path::Path>,
) -> Vec<SessionRow> {
let mut rows = Vec::new();
let canonical_cwd = canonicalize_or_self(cwd);
let canonical_project = project_filter.map(canonicalize_or_self);
let want = |h: Harness| harness_filter.is_none_or(|f| f == h);
if want(Harness::Claude)
&& let Some(mgr) = &bundle.claude
{
collect_claude(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows);
}
if want(Harness::Gemini)
&& let Some(mgr) = &bundle.gemini
{
collect_gemini(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows);
}
if want(Harness::Pi)
&& let Some(mgr) = &bundle.pi
{
collect_pi(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows);
}
if want(Harness::Codex)
&& let Some(mgr) = &bundle.codex
{
collect_codex(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows);
}
if want(Harness::Opencode)
&& let Some(mgr) = &bundle.opencode
{
collect_opencode(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows);
}
rows.sort_by(|a, b| {
b.matches_cwd
.cmp(&a.matches_cwd)
.then_with(|| b.last_activity.cmp(&a.last_activity))
});
rows
}
fn canonicalize_or_self(p: &std::path::Path) -> std::path::PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
}
fn paths_match(a: &std::path::Path, b: &std::path::Path) -> bool {
canonicalize_or_self(a) == canonicalize_or_self(b)
}
fn collect_claude(
mgr: &toolpath_claude::ClaudeConvo,
canonical_cwd: &std::path::Path,
project_filter: Option<&std::path::Path>,
out: &mut Vec<SessionRow>,
) {
let projects = match mgr.list_projects() {
Ok(ps) if !ps.is_empty() => ps,
Ok(_) => return,
Err(e) if is_not_found_claude(&e) => return,
Err(e) => {
eprintln!("warning: claude aggregation failed: {e}");
return;
}
};
for project in projects {
let project_path = std::path::Path::new(&project);
if let Some(filter) = project_filter
&& !paths_match(project_path, filter)
{
continue;
}
let metas = match mgr.list_conversation_metadata(&project) {
Ok(m) => m,
Err(e) => {
eprintln!("warning: claude project {project} failed: {e}");
continue;
}
};
let matches_cwd = paths_match(project_path, canonical_cwd);
for m in metas {
out.push(SessionRow {
harness: Harness::Claude,
project: Some(m.project_path),
cwd: None,
session_id: m.session_id,
title: m
.first_user_message
.unwrap_or_else(|| "(no prompt)".to_string()),
last_activity: m.last_activity,
message_count: m.message_count,
matches_cwd,
});
}
}
}
fn collect_gemini(
mgr: &toolpath_gemini::GeminiConvo,
canonical_cwd: &std::path::Path,
project_filter: Option<&std::path::Path>,
out: &mut Vec<SessionRow>,
) {
let projects = match mgr.list_projects() {
Ok(ps) if !ps.is_empty() => ps,
Ok(_) => return,
Err(e) if is_not_found_gemini(&e) => return,
Err(e) => {
eprintln!("warning: gemini aggregation failed: {e}");
return;
}
};
for project in projects {
let project_path = std::path::Path::new(&project);
if let Some(filter) = project_filter
&& !paths_match(project_path, filter)
{
continue;
}
let metas = match mgr.list_conversation_metadata(&project) {
Ok(m) => m,
Err(e) => {
eprintln!("warning: gemini project {project} failed: {e}");
continue;
}
};
let matches_cwd = paths_match(project_path, canonical_cwd);
for m in metas {
out.push(SessionRow {
harness: Harness::Gemini,
project: Some(m.project_path),
cwd: None,
session_id: m.session_uuid,
title: m
.first_user_message
.unwrap_or_else(|| "(no prompt)".to_string()),
last_activity: m.last_activity,
message_count: m.message_count,
matches_cwd,
});
}
}
}
fn collect_pi(
mgr: &toolpath_pi::PiConvo,
canonical_cwd: &std::path::Path,
project_filter: Option<&std::path::Path>,
out: &mut Vec<SessionRow>,
) {
let projects = match mgr.list_projects() {
Ok(ps) if !ps.is_empty() => ps,
Ok(_) => return,
Err(e) if is_not_found_pi(&e) => return,
Err(e) => {
eprintln!("warning: pi aggregation failed: {e}");
return;
}
};
for project in projects {
let project_path = std::path::Path::new(&project);
if let Some(filter) = project_filter
&& !paths_match(project_path, filter)
{
continue;
}
let metas = match mgr.list_sessions(&project) {
Ok(m) => m,
Err(e) => {
eprintln!("warning: pi project {project} failed: {e}");
continue;
}
};
let matches_cwd = paths_match(project_path, canonical_cwd);
for m in metas {
let last_activity = chrono::DateTime::parse_from_rfc3339(&m.timestamp)
.ok()
.map(|d| d.with_timezone(&Utc));
out.push(SessionRow {
harness: Harness::Pi,
project: Some(project.clone()),
cwd: None,
session_id: m.id,
title: m
.first_user_message
.unwrap_or_else(|| "(no prompt)".to_string()),
last_activity,
message_count: m.entry_count,
matches_cwd,
});
}
}
}
fn collect_codex(
mgr: &toolpath_codex::CodexConvo,
canonical_cwd: &std::path::Path,
project_filter: Option<&std::path::Path>,
out: &mut Vec<SessionRow>,
) {
let metas = match mgr.list_sessions() {
Ok(m) if !m.is_empty() => m,
Ok(_) => return,
Err(e) if is_not_found_codex(&e) => return,
Err(e) => {
eprintln!("warning: codex aggregation failed: {e}");
return;
}
};
for m in metas {
let cwd_str = m.cwd.as_ref().map(|p| p.to_string_lossy().into_owned());
if let Some(filter) = project_filter {
let stored = match cwd_str.as_deref() {
Some(s) => std::path::PathBuf::from(s),
None => continue,
};
if !paths_match(&stored, filter) {
continue;
}
}
let matches_cwd = m
.cwd
.as_deref()
.map(|p| paths_match(p, canonical_cwd))
.unwrap_or(false);
out.push(SessionRow {
harness: Harness::Codex,
project: None,
cwd: cwd_str,
session_id: m.id,
title: m
.first_user_message
.unwrap_or_else(|| "(no prompt)".to_string()),
last_activity: m.last_activity,
message_count: m.line_count,
matches_cwd,
});
}
}
fn collect_opencode(
mgr: &toolpath_opencode::OpencodeConvo,
canonical_cwd: &std::path::Path,
project_filter: Option<&std::path::Path>,
out: &mut Vec<SessionRow>,
) {
let metas = match mgr.io().list_session_metadata(None) {
Ok(m) if !m.is_empty() => m,
Ok(_) => return,
Err(e) if is_not_found_opencode(&e) => return,
Err(e) => {
eprintln!("warning: opencode aggregation failed: {e}");
return;
}
};
for m in metas {
if let Some(filter) = project_filter
&& !paths_match(&m.directory, filter)
{
continue;
}
let matches_cwd = paths_match(&m.directory, canonical_cwd);
let cwd_str = m.directory.to_string_lossy().into_owned();
let title = match (&m.first_user_message, m.title.is_empty()) {
(Some(s), _) if !s.is_empty() => s.clone(),
(_, false) => m.title.clone(),
_ => "(no prompt)".to_string(),
};
out.push(SessionRow {
harness: Harness::Opencode,
project: None,
cwd: Some(cwd_str),
session_id: m.id,
title,
last_activity: m.last_activity,
message_count: m.message_count,
matches_cwd,
});
}
}
fn is_not_found_claude(err: &toolpath_claude::ConvoError) -> bool {
use toolpath_claude::ConvoError;
matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound)
|| matches!(err, ConvoError::NoHomeDirectory)
|| matches!(err, ConvoError::ClaudeDirectoryNotFound(_))
}
fn is_not_found_gemini(err: &toolpath_gemini::ConvoError) -> bool {
use toolpath_gemini::ConvoError;
matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound)
|| matches!(err, ConvoError::NoHomeDirectory)
|| matches!(err, ConvoError::GeminiDirectoryNotFound(_))
}
fn is_not_found_pi(err: &toolpath_pi::PiError) -> bool {
use toolpath_pi::PiError;
matches!(err, PiError::Io(e) if e.kind() == std::io::ErrorKind::NotFound)
|| matches!(err, PiError::ProjectNotFound(_))
}
fn is_not_found_codex(err: &toolpath_codex::ConvoError) -> bool {
use toolpath_codex::ConvoError;
matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound)
|| matches!(err, ConvoError::NoHomeDirectory)
|| matches!(err, ConvoError::CodexDirectoryNotFound(_))
}
fn is_not_found_opencode(err: &toolpath_opencode::ConvoError) -> bool {
use toolpath_opencode::ConvoError;
matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound)
|| matches!(err, ConvoError::NoHomeDirectory)
|| matches!(err, ConvoError::OpencodeDirectoryNotFound(_))
|| matches!(err, ConvoError::DatabaseNotFound(_))
}
pub fn run(args: ShareArgs) -> Result<()> {
let harness = args.harness.map(Harness::from_arg);
if args.session.is_some() && harness.is_none() {
anyhow::bail!("--session requires --harness");
}
let upload_args = crate::cmd_export::PathbaseUploadArgs {
url: args.url.clone(),
anon: args.anon,
repo: args.repo.clone(),
name: args.name.clone(),
public: args.public,
};
let base_url = crate::cmd_export::resolve_upload_base_url(&upload_args);
let needs_auth = upload_args.repo.is_some() || upload_args.public || upload_args.name.is_some();
if let (Some(h), Some(session)) = (harness, &args.session) {
let auth = crate::cmd_pathbase::preflight_auth(&base_url, upload_args.anon, needs_auth)?;
return share_explicit(h, session.as_str(), &args, auth, base_url);
}
let cwd = std::env::current_dir()?;
let bundle = HarnessBundle::from_environment();
let project_filter = args.project.as_deref();
let rows = gather_sessions(&bundle, &cwd, harness, project_filter);
if rows.is_empty() {
return bail_no_sessions(&bundle, project_filter);
}
if !crate::fuzzy::available() {
eprintln!(
"Interactive `path share` needs `fzf` on PATH and a TTY.\n\
\n\
Manual recipe:\n \
path import <harness> # writes a cache entry, prints its id\n \
path export pathbase --input <id>"
);
anyhow::bail!("fzf unavailable; run `path import <harness>` then `path export pathbase`");
}
let auth = crate::cmd_pathbase::preflight_auth(&base_url, upload_args.anon, needs_auth)?;
let lines: Vec<String> = rows.iter().map(format_picker_row).collect();
let header = format!("share an agent session (Enter = upload to {base_url})");
let opts = crate::fuzzy::PickOptions {
with_nth: "4",
prompt: "share> ",
preview: Some("{exe} show --ansi {1} --project {2} --session {3}"),
preview_window: "up:60%:wrap-word",
header: Some(&header),
tiebreak: "index",
multi: false,
};
let line = match crate::fuzzy::pick(&lines, &opts)? {
crate::fuzzy::PickResult::Selected(v) => match v.into_iter().next() {
Some(l) => l,
None => return Ok(()),
},
crate::fuzzy::PickResult::NoMatch => return Ok(()),
crate::fuzzy::PickResult::Cancelled => std::process::exit(130),
};
let (h, key, session, title) = parse_picker_row(&line)
.ok_or_else(|| anyhow::anyhow!("internal: failed to parse picker row"))?;
let explicit = ShareArgs {
url: args.url.clone(),
anon: args.anon,
repo: args.repo.clone(),
name: args.name.clone(),
public: args.public,
harness: Some(harness_to_arg(h)),
session: None, project: if h.project_keyed() {
Some(PathBuf::from(&key))
} else {
None
},
no_cache: args.no_cache,
};
eprintln!("Picked {} session {:?}", h.name(), title);
share_explicit(h, &session, &explicit, auth, base_url)
}
fn harness_to_arg(h: Harness) -> HarnessArg {
match h {
Harness::Claude => HarnessArg::Claude,
Harness::Gemini => HarnessArg::Gemini,
Harness::Codex => HarnessArg::Codex,
Harness::Opencode => HarnessArg::Opencode,
Harness::Pi => HarnessArg::Pi,
}
}
fn bail_no_sessions(
bundle: &HarnessBundle,
project_filter: Option<&std::path::Path>,
) -> Result<()> {
if let Some(p) = project_filter {
anyhow::bail!(
"No agent sessions found in project {}. Run without --project to see sessions across all projects.",
p.display()
);
}
let mut summary = String::from("No agent sessions found.\n");
let home = home_dir();
summary.push_str(&format_status_line(
"claude",
&harness_status_claude(bundle, home.as_deref()),
));
summary.push_str(&format_status_line(
"gemini",
&harness_status_gemini(bundle, home.as_deref()),
));
summary.push_str(&format_status_line(
"codex",
&harness_status_codex(bundle, home.as_deref()),
));
summary.push_str(&format_status_line(
"opencode",
&harness_status_opencode(bundle, home.as_deref()),
));
summary.push_str(&format_status_line(
"pi",
&harness_status_pi(bundle, home.as_deref()),
));
eprint!("{summary}");
anyhow::bail!("no shareable sessions");
}
fn home_dir() -> Option<std::path::PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(std::path::PathBuf::from)
}
#[derive(Debug, PartialEq, Eq)]
struct HarnessStatus {
path: String,
exists: bool,
}
impl HarnessStatus {
fn render(&self) -> String {
if self.exists {
format!("{} (0 sessions)", self.path)
} else {
format!("{} not found", self.path)
}
}
fn unresolved() -> Self {
Self {
path: "<no home directory>".to_string(),
exists: false,
}
}
}
fn format_status_line(name: &str, status: &HarnessStatus) -> String {
format!(" {:<9} {}\n", format!("{name}:"), status.render())
}
fn harness_status_claude(bundle: &HarnessBundle, home: Option<&std::path::Path>) -> HarnessStatus {
let Some(mgr) = &bundle.claude else {
return HarnessStatus::unresolved();
};
match mgr.resolver().projects_dir() {
Ok(p) => HarnessStatus {
path: home_relative(&p, home),
exists: p.exists(),
},
Err(_) => HarnessStatus::unresolved(),
}
}
fn harness_status_gemini(bundle: &HarnessBundle, home: Option<&std::path::Path>) -> HarnessStatus {
let Some(mgr) = &bundle.gemini else {
return HarnessStatus::unresolved();
};
match mgr.resolver().tmp_dir() {
Ok(p) => HarnessStatus {
path: home_relative(&p, home),
exists: p.exists(),
},
Err(_) => HarnessStatus::unresolved(),
}
}
fn harness_status_codex(bundle: &HarnessBundle, home: Option<&std::path::Path>) -> HarnessStatus {
let Some(mgr) = &bundle.codex else {
return HarnessStatus::unresolved();
};
match mgr.resolver().sessions_root() {
Ok(p) => HarnessStatus {
path: home_relative(&p, home),
exists: p.exists(),
},
Err(_) => HarnessStatus::unresolved(),
}
}
fn harness_status_opencode(
bundle: &HarnessBundle,
home: Option<&std::path::Path>,
) -> HarnessStatus {
let Some(mgr) = &bundle.opencode else {
return HarnessStatus::unresolved();
};
match mgr.resolver().db_path() {
Ok(p) => HarnessStatus {
path: home_relative(&p, home),
exists: p.exists(),
},
Err(_) => HarnessStatus::unresolved(),
}
}
fn harness_status_pi(bundle: &HarnessBundle, home: Option<&std::path::Path>) -> HarnessStatus {
let Some(mgr) = &bundle.pi else {
return HarnessStatus::unresolved();
};
let p = mgr.resolver().sessions_dir().to_path_buf();
HarnessStatus {
path: home_relative(&p, home),
exists: p.exists(),
}
}
fn home_relative(path: &std::path::Path, home: Option<&std::path::Path>) -> String {
if let Some(home) = home
&& let Ok(rest) = path.strip_prefix(home)
{
if rest.as_os_str().is_empty() {
return "~".to_string();
}
return format!("~/{}", rest.display());
}
path.display().to_string()
}
fn share_explicit(
harness: Harness,
session: &str,
args: &ShareArgs,
auth: crate::cmd_pathbase::AuthMode,
base_url: String,
) -> Result<()> {
let project = match (harness.project_keyed(), args.project.as_ref()) {
(true, Some(p)) => Some(p.to_string_lossy().into_owned()),
(true, None) => anyhow::bail!(
"--project required when --harness is {} and --session is set",
harness.name()
),
(false, _) => None,
};
let derived = derive_session(harness, project.as_deref(), session)?;
let summary = format!("{} session {}", harness.name(), derived.cache_id);
if !args.no_cache {
let path = crate::cmd_cache::write_cached(&derived.cache_id, &derived.doc, true)?;
eprintln!(
"Cached {} session → {} ({})",
harness.name(),
derived.cache_id,
path.display()
);
}
let body = derived.doc.to_json()?;
let upload = crate::cmd_export::PathbaseUploadArgs {
url: args.url.clone(),
anon: args.anon,
repo: args.repo.clone(),
name: args.name.clone(),
public: args.public,
};
crate::cmd_export::run_pathbase_inner(auth, base_url, upload, &body, &summary)
}
fn format_picker_row(row: &SessionRow) -> String {
let key = row
.project
.clone()
.or_else(|| row.cwd.clone())
.unwrap_or_default();
let scope = if row.matches_cwd { "·" } else { " " };
let leading = format!("{scope} {}", row.harness.symbol());
let display = render_row(
Some(&leading),
row.last_activity,
&count(row.message_count, "msgs"),
Some(&project_short(&key)),
&row.title,
);
let title = clean_for_picker_display(&row.title);
format!(
"{}\t{}\t{}\t{}\t{}",
row.harness.name(),
tab_safe(&key),
tab_safe(&row.session_id),
display,
tab_safe(&title),
)
}
fn parse_picker_row(line: &str) -> Option<(Harness, String, String, String)> {
let mut parts = line.split('\t');
let h = Harness::parse(parts.next()?)?;
let key = parts.next()?.to_string();
let session = parts.next()?.to_string();
if session.is_empty() {
return None;
}
let title = parts.nth(1).unwrap_or("").to_string();
Some((h, key, session, title))
}
use crate::fuzzy::{clean_for_picker_display, count, project_short, render_row, tab_safe};
fn derive_session(
harness: Harness,
project: Option<&str>,
session: &str,
) -> Result<crate::cmd_import::DerivedDoc> {
match harness {
Harness::Claude => {
crate::cmd_import::derive_claude_session(project.expect("project_keyed"), session)
}
Harness::Gemini => crate::cmd_import::derive_gemini_session(
project.expect("project_keyed"),
session,
false,
),
Harness::Pi => {
crate::cmd_import::derive_pi_session(project.expect("project_keyed"), session, None)
}
Harness::Codex => crate::cmd_import::derive_codex_session(session),
Harness::Opencode => crate::cmd_import::derive_opencode_session(session, false),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn harness_name_and_symbol_are_distinct() {
let all = [
Harness::Claude,
Harness::Gemini,
Harness::Codex,
Harness::Opencode,
Harness::Pi,
];
let names: Vec<&str> = all.iter().map(|h| h.name()).collect();
let symbols: Vec<&str> = all.iter().map(|h| h.symbol()).collect();
assert_eq!(names.len(), 5);
assert_eq!(
names.iter().collect::<std::collections::HashSet<_>>().len(),
5,
"names must be unique"
);
assert_eq!(
symbols
.iter()
.collect::<std::collections::HashSet<_>>()
.len(),
5,
"symbols must be unique"
);
}
#[test]
fn harness_project_keyed_matches_design() {
assert!(Harness::Claude.project_keyed());
assert!(Harness::Gemini.project_keyed());
assert!(Harness::Pi.project_keyed());
assert!(!Harness::Codex.project_keyed());
assert!(!Harness::Opencode.project_keyed());
}
#[test]
fn harness_from_arg_roundtrips() {
for (arg, harness) in [
(HarnessArg::Claude, Harness::Claude),
(HarnessArg::Gemini, Harness::Gemini),
(HarnessArg::Codex, Harness::Codex),
(HarnessArg::Opencode, Harness::Opencode),
(HarnessArg::Pi, Harness::Pi),
] {
assert_eq!(Harness::from_arg(arg), harness);
}
}
use std::path::Path;
use tempfile::TempDir;
fn write_claude_session(claude_dir: &Path, project_slug: &str, session: &str, prompt: &str) {
let project_dir = claude_dir.join("projects").join(project_slug);
std::fs::create_dir_all(&project_dir).unwrap();
let user = format!(
r#"{{"type":"user","uuid":"u-{session}","timestamp":"2024-01-02T00:00:00Z","cwd":"/test/project","message":{{"role":"user","content":"{prompt}"}}}}"#
);
let asst = format!(
r#"{{"type":"assistant","uuid":"a-{session}","timestamp":"2024-01-02T00:00:01Z","message":{{"role":"assistant","content":"hi"}}}}"#
);
std::fs::write(
project_dir.join(format!("{session}.jsonl")),
format!("{user}\n{asst}\n"),
)
.unwrap();
}
fn claude_only_bundle(home: &Path) -> HarnessBundle {
let claude_dir = home.join(".claude");
std::fs::create_dir_all(&claude_dir).unwrap();
let resolver = toolpath_claude::PathResolver::new().with_claude_dir(&claude_dir);
HarnessBundle {
claude: Some(toolpath_claude::ClaudeConvo::with_resolver(resolver)),
..Default::default()
}
}
#[test]
fn gather_sessions_includes_claude_rows_for_a_project() {
let temp = TempDir::new().unwrap();
write_claude_session(
&temp.path().join(".claude"),
"-test-project",
"abc-session-one",
"Add a feature",
);
let bundle = claude_only_bundle(temp.path());
let cwd = Path::new("/test/project");
let rows = gather_sessions(&bundle, cwd, None, None);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].harness, Harness::Claude);
assert_eq!(rows[0].session_id, "abc-session-one");
assert_eq!(rows[0].project.as_deref(), Some("/test/project"));
assert!(rows[0].matches_cwd, "cwd should match the project path");
}
#[test]
fn gather_sessions_marks_non_matching_project_rows() {
let temp = TempDir::new().unwrap();
write_claude_session(
&temp.path().join(".claude"),
"-test-project",
"abc-session-one",
"Add a feature",
);
let bundle = claude_only_bundle(temp.path());
let cwd = Path::new("/some/other/place");
let rows = gather_sessions(&bundle, cwd, None, None);
assert_eq!(rows.len(), 1);
assert!(!rows[0].matches_cwd);
}
#[test]
fn gather_sessions_skips_harness_with_no_home_dir() {
let bundle = HarnessBundle::default();
let rows = gather_sessions(&bundle, Path::new("/anywhere"), None, None);
assert!(rows.is_empty());
}
#[test]
fn gather_sessions_filters_by_harness() {
let temp = TempDir::new().unwrap();
write_claude_session(
&temp.path().join(".claude"),
"-test-project",
"abc-session-one",
"hi",
);
let bundle = claude_only_bundle(temp.path());
let cwd = Path::new("/test/project");
let rows = gather_sessions(&bundle, cwd, Some(Harness::Codex), None);
assert!(rows.is_empty(), "filter to codex must drop claude rows");
}
fn codex_only_bundle(home: &Path) -> HarnessBundle {
let codex_dir = home.join(".codex");
std::fs::create_dir_all(&codex_dir).unwrap();
let resolver = toolpath_codex::PathResolver::new().with_codex_dir(&codex_dir);
HarnessBundle {
codex: Some(toolpath_codex::CodexConvo::with_resolver(resolver)),
..Default::default()
}
}
fn write_codex_session(codex_dir: &Path, id: &str, cwd: &str) {
let dir = codex_dir.join("sessions/2026/05/07");
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join(format!("rollout-2026-05-07T00-00-00-{id}.jsonl"));
let meta = format!(
r#"{{"timestamp":"2026-05-07T00:00:00Z","type":"session_meta","payload":{{"id":"{id}","timestamp":"2026-05-07T00:00:00Z","cwd":"{cwd}","originator":"codex-tui","cli_version":"test","source":"cli","model_provider":"openai"}}}}"#
);
let user = r#"{"timestamp":"2026-05-07T00:00:01Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}}"#;
std::fs::write(file, format!("{meta}\n{user}\n")).unwrap();
}
#[test]
fn gather_sessions_includes_codex_rows_with_cwd_match() {
let temp = TempDir::new().unwrap();
write_codex_session(
&temp.path().join(".codex"),
"00000000-0000-0000-0000-0000000000aa",
"/work/proj",
);
let bundle = codex_only_bundle(temp.path());
let rows = gather_sessions(&bundle, Path::new("/work/proj"), None, None);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].harness, Harness::Codex);
assert_eq!(rows[0].cwd.as_deref(), Some("/work/proj"));
assert!(rows[0].matches_cwd);
}
#[test]
fn gather_sessions_ranks_cwd_matches_first() {
let temp = TempDir::new().unwrap();
let claude_dir = temp.path().join(".claude");
write_claude_session(&claude_dir, "-cwd-project", "in-cwd-session", "hi");
let not_dir = claude_dir.join("projects").join("-other-project");
std::fs::create_dir_all(¬_dir).unwrap();
std::fs::write(
not_dir.join("not-in-cwd-session.jsonl"),
r#"{"type":"user","uuid":"u-x","timestamp":"2030-01-01T00:00:00Z","cwd":"/other/project","message":{"role":"user","content":"later"}}"#.to_string()
+ "\n",
)
.unwrap();
let bundle = claude_only_bundle(temp.path());
let rows = gather_sessions(&bundle, Path::new("/cwd/project"), None, None);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].session_id, "in-cwd-session");
assert!(rows[0].matches_cwd);
assert!(!rows[1].matches_cwd);
}
#[test]
#[cfg(unix)]
fn paths_match_canonicalizes_through_symlink() {
let temp = TempDir::new().unwrap();
let real_project = temp.path().join("real-project");
std::fs::create_dir_all(&real_project).unwrap();
let symlink_path = temp.path().join("symlink-to-project");
std::os::unix::fs::symlink(&real_project, &symlink_path).unwrap();
assert_ne!(real_project, symlink_path);
assert_eq!(
std::fs::canonicalize(&real_project).unwrap(),
std::fs::canonicalize(&symlink_path).unwrap(),
);
assert!(
paths_match(&real_project, &symlink_path),
"paths_match must canonicalize both sides so symlink == target"
);
assert!(
paths_match(&symlink_path, &real_project),
"paths_match must be symmetric across the symlink"
);
}
#[test]
fn parse_picker_row_roundtrips_keyed() {
let row = SessionRow {
harness: Harness::Claude,
project: Some("/tmp/proj".to_string()),
cwd: None,
session_id: "sess-abc".to_string(),
title: "Hello\tworld".to_string(),
last_activity: None,
message_count: 3,
matches_cwd: true,
};
let line = format_picker_row(&row);
let (harness, key, session, title) = parse_picker_row(&line).unwrap();
assert_eq!(harness, Harness::Claude);
assert_eq!(key, "/tmp/proj");
assert_eq!(session, "sess-abc");
assert_eq!(title, "Hello world");
}
#[test]
fn parse_picker_row_roundtrips_session_keyed() {
let row = SessionRow {
harness: Harness::Codex,
project: None,
cwd: Some("/work/proj".to_string()),
session_id: "0190abcd".to_string(),
title: "(no prompt)".to_string(),
last_activity: None,
message_count: 0,
matches_cwd: false,
};
let line = format_picker_row(&row);
let (harness, key, session, title) = parse_picker_row(&line).unwrap();
assert_eq!(harness, Harness::Codex);
assert_eq!(key, "/work/proj"); assert_eq!(session, "0190abcd");
assert_eq!(title, "(no prompt)");
}
#[test]
fn parse_picker_row_carries_title_with_unicode() {
let row = SessionRow {
harness: Harness::Gemini,
project: Some("/work/proj".to_string()),
cwd: None,
session_id: "11111111-2222-3333-4444-555555555555".to_string(),
title: "Add the share command — finally".to_string(),
last_activity: None,
message_count: 42,
matches_cwd: true,
};
let line = format_picker_row(&row);
let (_, _, _, title) = parse_picker_row(&line).unwrap();
assert_eq!(title, "Add the share command — finally");
}
#[test]
fn home_relative_strips_home_prefix() {
let home = Path::new("/Users/alex");
assert_eq!(
home_relative(Path::new("/Users/alex/.claude/projects"), Some(home)),
"~/.claude/projects"
);
}
#[test]
fn home_relative_returns_tilde_for_home_itself() {
let home = Path::new("/Users/alex");
assert_eq!(home_relative(home, Some(home)), "~");
}
#[test]
fn home_relative_passes_through_paths_outside_home() {
let home = Path::new("/Users/alex");
assert_eq!(
home_relative(Path::new("/tmp/elsewhere"), Some(home)),
"/tmp/elsewhere"
);
}
#[test]
fn home_relative_passes_through_when_no_home() {
assert_eq!(home_relative(Path::new("/foo/bar"), None), "/foo/bar");
}
#[test]
fn harness_status_renders_existing_path_with_zero_sessions() {
let s = HarnessStatus {
path: "~/.claude/projects".to_string(),
exists: true,
};
assert_eq!(s.render(), "~/.claude/projects (0 sessions)");
}
#[test]
fn harness_status_renders_missing_path_as_not_found() {
let s = HarnessStatus {
path: "~/.gemini/tmp".to_string(),
exists: false,
};
assert_eq!(s.render(), "~/.gemini/tmp not found");
}
#[test]
fn format_status_line_pads_for_alignment() {
let s = HarnessStatus {
path: "~/.codex/sessions".to_string(),
exists: true,
};
let claude_line = format_status_line("claude", &s);
let opencode_line = format_status_line("opencode", &s);
let pi_line = format_status_line("pi", &s);
let offset = |line: &str| line.find('~').unwrap();
assert_eq!(offset(&claude_line), offset(&opencode_line));
assert_eq!(offset(&claude_line), offset(&pi_line));
}
#[test]
fn harness_status_for_missing_claude_dir_reports_not_found() {
let temp = TempDir::new().unwrap();
let claude_dir = temp.path().join(".claude"); let resolver = toolpath_claude::PathResolver::new().with_claude_dir(&claude_dir);
let bundle = HarnessBundle {
claude: Some(toolpath_claude::ClaudeConvo::with_resolver(resolver)),
..Default::default()
};
let status = harness_status_claude(&bundle, None);
assert!(!status.exists, "missing dir must report exists=false");
assert!(
status.path.contains("projects"),
"path must include the projects subdir (got {:?})",
status.path
);
}
#[test]
fn harness_status_for_present_claude_dir_reports_existence() {
let temp = TempDir::new().unwrap();
let claude_dir = temp.path().join(".claude");
std::fs::create_dir_all(claude_dir.join("projects")).unwrap();
let resolver = toolpath_claude::PathResolver::new().with_claude_dir(&claude_dir);
let bundle = HarnessBundle {
claude: Some(toolpath_claude::ClaudeConvo::with_resolver(resolver)),
..Default::default()
};
let status = harness_status_claude(&bundle, None);
assert!(status.exists);
}
#[test]
fn harness_status_for_empty_bundle_is_unresolved() {
let bundle = HarnessBundle::default();
for status in [
harness_status_claude(&bundle, None),
harness_status_gemini(&bundle, None),
harness_status_codex(&bundle, None),
harness_status_opencode(&bundle, None),
harness_status_pi(&bundle, None),
] {
assert_eq!(status, HarnessStatus::unresolved());
assert!(!status.exists);
}
}
}