#[cfg(not(target_os = "emscripten"))]
use anyhow::Context;
use anyhow::Result;
use clap::Subcommand;
use std::path::PathBuf;
#[derive(Subcommand, Debug)]
pub enum ListSource {
Git {
#[arg(short, long, default_value = ".")]
repo: PathBuf,
#[arg(long, default_value = "origin")]
remote: String,
},
Github {
#[arg(short, long)]
repo: String,
},
Claude {
#[arg(short, long)]
project: Option<String>,
},
}
pub fn run(source: ListSource, json: bool) -> Result<()> {
match source {
ListSource::Git { repo, remote } => run_git(repo, remote, json),
ListSource::Github { repo } => run_github(repo, json),
ListSource::Claude { project } => run_claude(project, json),
}
}
fn run_git(repo_path: PathBuf, remote: String, json: bool) -> Result<()> {
#[cfg(target_os = "emscripten")]
{
let _ = (repo_path, remote, json);
anyhow::bail!(
"'path list git' requires a native environment with access to a git repository"
);
}
#[cfg(not(target_os = "emscripten"))]
{
let repo_path = if repo_path.is_absolute() {
repo_path
} else {
std::env::current_dir()?.join(&repo_path)
};
let repo = git2::Repository::open(&repo_path)
.with_context(|| format!("Failed to open repository at {:?}", repo_path))?;
let uri = toolpath_git::get_repo_uri(&repo, &remote)?;
let branches = toolpath_git::list_branches(&repo)?;
if json {
let items: Vec<serde_json::Value> = branches
.iter()
.map(|b| {
serde_json::json!({
"name": b.name,
"head": b.head,
"subject": b.subject,
"author": b.author,
"timestamp": b.timestamp,
})
})
.collect();
let output = serde_json::json!({
"source": "git",
"uri": uri,
"branches": items,
});
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
println!("Repository: {}", uri);
println!();
if branches.is_empty() {
println!(" (no local branches)");
} else {
for b in &branches {
println!(" {} {} {}", b.head_short, b.name, truncate(&b.subject, 60));
}
}
}
Ok(())
}
}
fn run_github(repo: String, json: bool) -> Result<()> {
#[cfg(target_os = "emscripten")]
{
let _ = (repo, json);
anyhow::bail!("'path list github' requires a native environment with network access");
}
#[cfg(not(target_os = "emscripten"))]
{
let (owner, repo_name) = repo
.split_once('/')
.ok_or_else(|| anyhow::anyhow!("Repository must be in owner/repo format"))?;
let token = toolpath_github::resolve_token()?;
let config = toolpath_github::DeriveConfig {
token,
..Default::default()
};
let prs = toolpath_github::list_pull_requests(owner, repo_name, &config)?;
if json {
let items: Vec<serde_json::Value> = prs
.iter()
.map(|pr| {
serde_json::json!({
"number": pr.number,
"title": pr.title,
"state": pr.state,
"author": pr.author,
"head_branch": pr.head_branch,
"base_branch": pr.base_branch,
"created_at": pr.created_at,
"updated_at": pr.updated_at,
})
})
.collect();
let output = serde_json::json!({
"source": "github",
"repo": format!("{}/{}", owner, repo_name),
"pull_requests": items,
});
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
println!("Pull requests for {}/{}:", owner, repo_name);
println!();
if prs.is_empty() {
println!(" (none)");
} else {
for pr in &prs {
println!(
" #{:<5} {:>8} {} {}",
pr.number,
pr.state,
pr.author,
truncate(&pr.title, 50),
);
}
}
}
Ok(())
}
}
fn run_claude(project: Option<String>, json: bool) -> Result<()> {
let manager = toolpath_claude::ClaudeConvo::new();
match project {
None => list_claude_projects(&manager, json),
Some(project_path) => list_claude_sessions(&manager, &project_path, json),
}
}
fn list_claude_projects(manager: &toolpath_claude::ClaudeConvo, json: bool) -> Result<()> {
let projects = manager
.list_projects()
.map_err(|e| anyhow::anyhow!("{}", e))?;
if json {
let items: Vec<serde_json::Value> = projects
.iter()
.map(|p| serde_json::json!({ "path": p }))
.collect();
let output = serde_json::json!({
"source": "claude",
"projects": items,
});
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
println!("Claude projects:");
println!();
if projects.is_empty() {
println!(" (none)");
} else {
for p in &projects {
println!(" {}", p);
}
}
}
Ok(())
}
fn list_claude_sessions(
manager: &toolpath_claude::ClaudeConvo,
project_path: &str,
json: bool,
) -> Result<()> {
let metadata = manager
.list_conversation_metadata(project_path)
.map_err(|e| anyhow::anyhow!("{}", e))?;
if json {
let items: Vec<serde_json::Value> = metadata
.iter()
.map(|m| {
serde_json::json!({
"session_id": m.session_id,
"messages": m.message_count,
"started_at": m.started_at.map(|t| t.to_rfc3339()),
"last_activity": m.last_activity.map(|t| t.to_rfc3339()),
})
})
.collect();
let output = serde_json::json!({
"source": "claude",
"project": project_path,
"sessions": items,
});
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
println!("Sessions for {}:", project_path);
println!();
if metadata.is_empty() {
println!(" (none)");
} else {
for m in &metadata {
let date = m
.last_activity
.map(|t| t.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| "unknown".to_string());
println!(" {} {:>4} msgs {}", &m.session_id, m.message_count, date);
}
}
}
Ok(())
}
#[cfg(not(target_os = "emscripten"))]
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let truncated: String = s.chars().take(max - 3).collect();
format!("{}...", truncated)
}
}
#[cfg(all(test, not(target_os = "emscripten")))]
mod tests {
use super::*;
fn init_temp_repo() -> (tempfile::TempDir, git2::Repository) {
let dir = tempfile::tempdir().unwrap();
let repo = git2::Repository::init(dir.path()).unwrap();
let mut config = repo.config().unwrap();
config.set_str("user.name", "Test User").unwrap();
config.set_str("user.email", "test@example.com").unwrap();
(dir, repo)
}
fn create_commit(
repo: &git2::Repository,
message: &str,
file_name: &str,
content: &str,
parent: Option<&git2::Commit>,
) -> git2::Oid {
let mut index = repo.index().unwrap();
let file_path = repo.workdir().unwrap().join(file_name);
std::fs::write(&file_path, content).unwrap();
index.add_path(std::path::Path::new(file_name)).unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let sig = repo.signature().unwrap();
let parents: Vec<&git2::Commit> = parent.into_iter().collect();
repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parents)
.unwrap()
}
#[test]
fn test_run_git_human_readable() {
let (dir, repo) = init_temp_repo();
create_commit(&repo, "initial commit", "file.txt", "hello", None);
let result = run_git(dir.path().to_path_buf(), "origin".to_string(), false);
assert!(result.is_ok());
}
#[test]
fn test_run_git_json() {
let (dir, repo) = init_temp_repo();
create_commit(&repo, "initial commit", "file.txt", "hello", None);
let result = run_git(dir.path().to_path_buf(), "origin".to_string(), true);
assert!(result.is_ok());
}
#[test]
fn test_run_git_invalid_repo() {
let dir = tempfile::tempdir().unwrap();
let result = run_git(dir.path().to_path_buf(), "origin".to_string(), false);
assert!(result.is_err());
}
#[test]
fn test_truncate_short() {
assert_eq!(truncate("hello", 10), "hello");
}
#[test]
fn test_truncate_exact() {
assert_eq!(truncate("hello", 5), "hello");
}
#[test]
fn test_truncate_long() {
let result = truncate("hello world, this is a long string", 15);
assert!(result.ends_with("..."));
assert_eq!(result.chars().count(), 15);
}
#[test]
fn test_truncate_multibyte() {
let result = truncate("日本語のテスト文字列です", 8);
assert!(result.ends_with("..."));
assert_eq!(result.chars().count(), 8);
}
fn setup_claude_manager() -> (tempfile::TempDir, toolpath_claude::ClaudeConvo) {
let temp = tempfile::tempdir().unwrap();
let claude_dir = temp.path().join(".claude");
let project_dir = claude_dir.join("projects/-test-project");
std::fs::create_dir_all(&project_dir).unwrap();
let entry1 = r#"{"type":"user","uuid":"uuid-1","timestamp":"2024-01-01T00:00:00Z","cwd":"/test/project","message":{"role":"user","content":"Hello"}}"#;
let entry2 = r#"{"type":"assistant","uuid":"uuid-2","timestamp":"2024-01-01T00:01:00Z","message":{"role":"assistant","content":"Hi there"}}"#;
std::fs::write(
project_dir.join("session-abc.jsonl"),
format!("{}\n{}\n", entry1, entry2),
)
.unwrap();
let resolver = toolpath_claude::PathResolver::new().with_claude_dir(&claude_dir);
let manager = toolpath_claude::ClaudeConvo::with_resolver(resolver);
(temp, manager)
}
#[test]
fn test_list_claude_projects_human() {
let (_temp, manager) = setup_claude_manager();
let result = list_claude_projects(&manager, false);
assert!(result.is_ok());
}
#[test]
fn test_list_claude_projects_json() {
let (_temp, manager) = setup_claude_manager();
let result = list_claude_projects(&manager, true);
assert!(result.is_ok());
}
#[test]
fn test_list_claude_projects_empty() {
let temp = tempfile::tempdir().unwrap();
let claude_dir = temp.path().join(".claude");
let projects_dir = claude_dir.join("projects");
std::fs::create_dir_all(&projects_dir).unwrap();
let resolver = toolpath_claude::PathResolver::new().with_claude_dir(&claude_dir);
let manager = toolpath_claude::ClaudeConvo::with_resolver(resolver);
let result = list_claude_projects(&manager, false);
assert!(result.is_ok());
}
#[test]
fn test_list_claude_sessions_human() {
let (_temp, manager) = setup_claude_manager();
let result = list_claude_sessions(&manager, "/test/project", false);
assert!(result.is_ok());
}
#[test]
fn test_list_claude_sessions_json() {
let (_temp, manager) = setup_claude_manager();
let result = list_claude_sessions(&manager, "/test/project", true);
assert!(result.is_ok());
}
#[test]
fn test_list_claude_sessions_empty() {
let temp = tempfile::tempdir().unwrap();
let claude_dir = temp.path().join(".claude");
let projects_dir = claude_dir.join("projects/-empty-project");
std::fs::create_dir_all(&projects_dir).unwrap();
let resolver = toolpath_claude::PathResolver::new().with_claude_dir(&claude_dir);
let manager = toolpath_claude::ClaudeConvo::with_resolver(resolver);
let result = list_claude_sessions(&manager, "/empty/project", false);
assert!(result.is_ok());
}
#[test]
fn test_run_claude_projects() {
let (_temp, manager) = setup_claude_manager();
let result = list_claude_projects(&manager, false);
assert!(result.is_ok());
}
}