#[cfg(not(target_os = "emscripten"))]
use anyhow::Context;
use anyhow::Result;
use clap::Subcommand;
use std::path::PathBuf;
#[derive(Subcommand, Debug)]
pub enum DeriveSource {
Git {
#[arg(short, long, default_value = ".")]
repo: PathBuf,
#[arg(short, long, required = true)]
branch: Vec<String>,
#[arg(long)]
base: Option<String>,
#[arg(long, default_value = "origin")]
remote: String,
#[arg(long)]
title: Option<String>,
},
Github {
#[arg(index = 1)]
url: Option<String>,
#[arg(short, long)]
repo: Option<String>,
#[arg(long)]
pr: Option<u64>,
#[arg(long)]
no_ci: bool,
#[arg(long)]
no_comments: bool,
},
Claude {
#[arg(short, long)]
project: String,
#[arg(short, long)]
session: Option<String>,
#[arg(long)]
all: bool,
},
}
pub fn run(source: DeriveSource, pretty: bool) -> Result<()> {
match source {
DeriveSource::Git {
repo,
branch,
base,
remote,
title,
} => run_git(repo, branch, base, remote, title, pretty),
DeriveSource::Github {
url,
repo,
pr,
no_ci,
no_comments,
} => run_github(url, repo, pr, no_ci, no_comments, pretty),
DeriveSource::Claude {
project,
session,
all,
} => run_claude(project, session, all, pretty),
}
}
fn run_git(
repo_path: PathBuf,
branches: Vec<String>,
base: Option<String>,
remote: String,
title: Option<String>,
pretty: bool,
) -> Result<()> {
#[cfg(target_os = "emscripten")]
{
let _ = (repo_path, branches, base, remote, title, pretty);
anyhow::bail!(
"'path derive 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 config = toolpath_git::DeriveConfig {
remote,
title,
base,
};
let doc = toolpath_git::derive(&repo, &branches, &config)?;
let json = if pretty {
doc.to_json_pretty()?
} else {
doc.to_json()?
};
println!("{}", json);
Ok(())
}
}
fn run_github(
url: Option<String>,
repo: Option<String>,
pr: Option<u64>,
no_ci: bool,
no_comments: bool,
pretty: bool,
) -> Result<()> {
#[cfg(target_os = "emscripten")]
{
let _ = (url, repo, pr, no_ci, no_comments, pretty);
anyhow::bail!("'path derive github' requires a native environment with network access");
}
#[cfg(not(target_os = "emscripten"))]
{
let (owner, repo_name, pr_number) = if let Some(url_str) = &url {
let parsed = toolpath_github::parse_pr_url(url_str).ok_or_else(|| {
anyhow::anyhow!("Invalid PR URL. Expected: https://github.com/owner/repo/pull/N")
})?;
(parsed.owner, parsed.repo, parsed.number)
} else if let (Some(repo_str), Some(pr_num)) = (&repo, pr) {
let (o, r) = repo_str
.split_once('/')
.ok_or_else(|| anyhow::anyhow!("Repository must be in owner/repo format"))?;
(o.to_string(), r.to_string(), pr_num)
} else {
anyhow::bail!(
"Provide a PR URL or both --repo and --pr.\n\
Usage: path derive github https://github.com/owner/repo/pull/42\n\
Usage: path derive github --repo owner/repo --pr 42"
);
};
let token = toolpath_github::resolve_token()?;
let config = toolpath_github::DeriveConfig {
token,
include_ci: !no_ci,
include_comments: !no_comments,
..Default::default()
};
let path = toolpath_github::derive_pull_request(&owner, &repo_name, pr_number, &config)?;
let doc = toolpath::v1::Document::Path(path);
let json = if pretty {
doc.to_json_pretty()?
} else {
doc.to_json()?
};
println!("{}", json);
Ok(())
}
}
fn run_claude(project: String, session: Option<String>, all: bool, pretty: bool) -> Result<()> {
let manager = toolpath_claude::ClaudeConvo::new();
run_claude_with_manager(&manager, project, session, all, pretty)
}
fn run_claude_with_manager(
manager: &toolpath_claude::ClaudeConvo,
project: String,
session: Option<String>,
all: bool,
pretty: bool,
) -> Result<()> {
let config = toolpath_claude::derive::DeriveConfig {
project_path: Some(project.clone()),
include_thinking: false,
};
let docs: Vec<toolpath::v1::Path> = if let Some(session_id) = session {
let convo = manager
.read_conversation(&project, &session_id)
.map_err(|e| anyhow::anyhow!("{}", e))?;
vec![toolpath_claude::derive::derive_path(&convo, &config)]
} else if all {
let convos = manager
.read_all_conversations(&project)
.map_err(|e| anyhow::anyhow!("{}", e))?;
toolpath_claude::derive::derive_project(&convos, &config)
} else {
let convo = manager
.most_recent_conversation(&project)
.map_err(|e| anyhow::anyhow!("{}", e))?
.ok_or_else(|| anyhow::anyhow!("No conversations found for project: {}", project))?;
vec![toolpath_claude::derive::derive_path(&convo, &config)]
};
for path in &docs {
let doc = toolpath::v1::Document::Path(path.clone());
let json = if pretty {
doc.to_json_pretty()?
} else {
doc.to_json()?
};
println!("{}", json);
}
Ok(())
}
#[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_single_branch() {
let (dir, repo) = init_temp_repo();
let oid = create_commit(&repo, "initial commit", "file.txt", "hello", None);
let c1 = repo.find_commit(oid).unwrap();
create_commit(&repo, "second", "file.txt", "world", Some(&c1));
let default = toolpath_git::list_branches(&repo)
.unwrap()
.first()
.unwrap()
.name
.clone();
let result = run_git(
dir.path().to_path_buf(),
vec![default],
None,
"origin".to_string(),
None,
false,
);
assert!(result.is_ok());
}
#[test]
fn test_run_git_pretty() {
let (dir, repo) = init_temp_repo();
create_commit(&repo, "initial", "file.txt", "hello", None);
let default = toolpath_git::list_branches(&repo)
.unwrap()
.first()
.unwrap()
.name
.clone();
let result = run_git(
dir.path().to_path_buf(),
vec![default],
None,
"origin".to_string(),
None,
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(),
vec!["main".to_string()],
None,
"origin".to_string(),
None,
false,
);
assert!(result.is_err());
}
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:00:01Z","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_run_claude_session() {
let (_temp, manager) = setup_claude_manager();
let result = run_claude_with_manager(
&manager,
"/test/project".to_string(),
Some("session-abc".to_string()),
false,
false,
);
assert!(result.is_ok());
}
#[test]
fn test_run_claude_session_pretty() {
let (_temp, manager) = setup_claude_manager();
let result = run_claude_with_manager(
&manager,
"/test/project".to_string(),
Some("session-abc".to_string()),
false,
true,
);
assert!(result.is_ok());
}
#[test]
fn test_run_claude_most_recent() {
let (_temp, manager) = setup_claude_manager();
let result =
run_claude_with_manager(&manager, "/test/project".to_string(), None, false, false);
assert!(result.is_ok());
}
#[test]
fn test_run_claude_all() {
let (_temp, manager) = setup_claude_manager();
let result =
run_claude_with_manager(&manager, "/test/project".to_string(), None, true, false);
assert!(result.is_ok());
}
#[test]
fn test_run_claude_no_conversations() {
let temp = tempfile::tempdir().unwrap();
let claude_dir = temp.path().join(".claude");
let project_dir = claude_dir.join("projects/-empty-project");
std::fs::create_dir_all(&project_dir).unwrap();
let resolver = toolpath_claude::PathResolver::new().with_claude_dir(&claude_dir);
let manager = toolpath_claude::ClaudeConvo::with_resolver(resolver);
let result =
run_claude_with_manager(&manager, "/empty/project".to_string(), None, false, false);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("No conversations found")
);
}
}