use assert_cmd::Command;
use predicates::prelude::*;
use std::path::PathBuf;
fn examples_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("examples")
}
fn cmd() -> Command {
Command::cargo_bin("path").unwrap()
}
fn git_fixture() -> (tempfile::TempDir, String) {
let dir = tempfile::tempdir().unwrap();
let repo = git2::Repository::init(dir.path()).unwrap();
let mut config = repo.config().unwrap();
config.set_str("user.name", "Alice Dev").unwrap();
config.set_str("user.email", "alice@example.com").unwrap();
let mut index = repo.index().unwrap();
std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
index.add_path(std::path::Path::new("main.rs")).unwrap();
index.write().unwrap();
let tree1 = repo.find_tree(index.write_tree().unwrap()).unwrap();
let sig = repo.signature().unwrap();
let oid1 = repo
.commit(Some("HEAD"), &sig, &sig, "initial commit", &tree1, &[])
.unwrap();
let commit1 = repo.find_commit(oid1).unwrap();
std::fs::write(dir.path().join("main.rs"), "fn main() { fixed() }").unwrap();
index.add_path(std::path::Path::new("main.rs")).unwrap();
index.write().unwrap();
let tree2 = repo.find_tree(index.write_tree().unwrap()).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "fix the bug", &tree2, &[&commit1])
.unwrap();
let head = repo.head().unwrap();
let branch = head.shorthand().unwrap().to_string();
(dir, branch)
}
#[test]
fn validate_valid_step() {
cmd()
.args(["p", "validate"])
.arg("--input")
.arg(examples_dir().join("step-01-minimal.json"))
.assert()
.success()
.stdout(predicate::str::contains("Valid"));
}
#[test]
fn validate_invalid_json() {
let dir = std::env::temp_dir();
let tmp_file = dir.join("toolpath-integration-invalid.json");
std::fs::write(&tmp_file, "{ not valid json }").unwrap();
cmd()
.args(["p", "validate"])
.arg("--input")
.arg(&tmp_file)
.assert()
.failure();
let _ = std::fs::remove_file(&tmp_file);
}
#[test]
fn derive_git_produces_path() {
let (dir, branch) = git_fixture();
cmd()
.args(["p", "derive"])
.arg("git")
.arg("--repo")
.arg(dir.path())
.arg("--branch")
.arg(&branch)
.assert()
.success()
.stdout(predicate::str::contains("\"graph\":"))
.stdout(predicate::str::contains("\"paths\":"))
.stdout(predicate::str::contains("\"head\":"))
.stdout(predicate::str::contains("\"steps\""));
}
#[test]
fn derive_git_has_correct_actor() {
let (dir, branch) = git_fixture();
let output = cmd()
.args(["p", "derive"])
.arg("git")
.arg("--repo")
.arg(dir.path())
.arg("--branch")
.arg(&branch)
.arg("--pretty")
.output()
.unwrap();
assert!(output.status.success());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let path = &json["paths"][0];
let step = &path["steps"][0];
assert_eq!(step["step"]["actor"], "human:alice");
let actors = &path["meta"]["actors"];
let alice = &actors["human:alice"];
assert_eq!(alice["name"], "Alice Dev");
assert_eq!(alice["identities"][0]["id"], "alice@example.com");
}
#[test]
fn derive_git_has_change_with_diff() {
let (dir, branch) = git_fixture();
let output = cmd()
.args(["p", "derive"])
.arg("git")
.arg("--repo")
.arg(dir.path())
.arg("--branch")
.arg(&branch)
.arg("--pretty")
.output()
.unwrap();
assert!(output.status.success());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let step = &json["paths"][0]["steps"][0];
let change = &step["change"]["main.rs"];
let raw = change["raw"].as_str().unwrap();
assert!(
raw.contains("-fn main() {}"),
"diff should show old content"
);
assert!(
raw.contains("+fn main() { fixed() }"),
"diff should show new content"
);
}
#[test]
fn derive_git_has_intent_from_commit_message() {
let (dir, branch) = git_fixture();
let output = cmd()
.args(["p", "derive"])
.arg("git")
.arg("--repo")
.arg(dir.path())
.arg("--branch")
.arg(&branch)
.arg("--pretty")
.output()
.unwrap();
assert!(output.status.success());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let step = &json["paths"][0]["steps"][0];
assert_eq!(step["meta"]["intent"], "fix the bug");
}
#[test]
fn derive_git_has_base_uri() {
let (dir, branch) = git_fixture();
let output = cmd()
.args(["p", "derive"])
.arg("git")
.arg("--repo")
.arg(dir.path())
.arg("--branch")
.arg(&branch)
.arg("--pretty")
.output()
.unwrap();
assert!(output.status.success());
let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let base = &json["paths"][0]["path"]["base"];
let uri = base["uri"].as_str().unwrap();
assert!(
uri.starts_with("file://"),
"Expected file:// URI, got {}",
uri
);
let git_ref = base["ref"].as_str().unwrap();
assert_eq!(git_ref.len(), 40);
assert!(git_ref.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn derive_git_validate_roundtrip() {
let (dir, branch) = git_fixture();
let tmp_file = std::env::temp_dir().join("toolpath-integration-roundtrip.json");
let derive_output = cmd()
.args(["p", "derive"])
.arg("git")
.arg("--repo")
.arg(dir.path())
.arg("--branch")
.arg(&branch)
.output()
.unwrap();
assert!(derive_output.status.success());
std::fs::write(&tmp_file, &derive_output.stdout).unwrap();
cmd()
.args(["p", "validate"])
.arg("--input")
.arg(&tmp_file)
.assert()
.success()
.stdout(predicate::str::contains("Valid"));
let _ = std::fs::remove_file(&tmp_file);
}
#[test]
fn render_dot_from_stdin() {
let input = std::fs::read_to_string(examples_dir().join("path-01-pr.path.json")).unwrap();
cmd()
.args(["p", "render"])
.arg("dot")
.write_stdin(input)
.assert()
.success()
.stdout(predicate::str::contains("digraph"));
}
#[test]
fn query_dead_ends() {
cmd()
.arg("query")
.arg("--input")
.arg(examples_dir().join("path-01-pr.path.json"))
.arg("map(select(.dead_end))")
.assert()
.success()
.stdout(predicate::str::contains("step-002a"));
}
#[test]
fn query_ancestors() {
cmd()
.args(["p", "query", "ancestors"])
.arg("--input")
.arg(examples_dir().join("path-01-pr.path.json"))
.arg("--step-id")
.arg("step-004")
.assert()
.success()
.stdout(predicate::str::contains("step-001"))
.stdout(predicate::str::contains("step-004"));
}
#[test]
fn merge_produces_graph() {
cmd()
.args(["p", "merge"])
.arg(examples_dir().join("path-01-pr.path.json"))
.arg(examples_dir().join("path-02-local-session.path.json"))
.assert()
.success()
.stdout(predicate::str::contains("\"graph\":"))
.stdout(predicate::str::contains("\"paths\":"));
}
#[test]
fn validate_accepts_path_jsonl() {
cmd()
.args(["p", "validate"])
.arg("--input")
.arg(examples_dir().join("path-02-local-session.path.jsonl"))
.assert()
.success()
.stdout(predicate::str::contains("Valid: Graph"));
}
#[test]
fn validate_rejects_truncated_jsonl() {
let mut f = tempfile::Builder::new()
.suffix(".path.jsonl")
.tempfile()
.unwrap();
use std::io::Write;
writeln!(f, r#"{{"Step":"garbage"}}"#).unwrap();
f.flush().unwrap();
cmd()
.args(["p", "validate"])
.arg("--input")
.arg(f.path())
.assert()
.failure()
.stderr(predicate::str::contains("Invalid"));
}
#[test]
fn render_md_accepts_path_jsonl() {
cmd()
.args(["p", "render"])
.arg("md")
.arg("--input")
.arg(examples_dir().join("path-03-signed-pr.path.jsonl"))
.assert()
.success()
.stdout(predicate::str::is_empty().not());
}
#[test]
fn query_dead_ends_accepts_path_jsonl() {
cmd()
.arg("query")
.arg("--input")
.arg(examples_dir().join("path-04-exploration.path.jsonl"))
.arg("map(select(.dead_end))")
.assert()
.success();
}
#[test]
fn merge_accepts_path_jsonl() {
cmd()
.args(["p", "merge"])
.arg(examples_dir().join("path-01-pr.path.jsonl"))
.arg(examples_dir().join("path-02-local-session.path.jsonl"))
.assert()
.success()
.stdout(predicate::str::contains("\"graph\":"))
.stdout(predicate::str::contains("\"paths\":"));
}
#[test]
fn auth_help_lists_subcommands() {
cmd()
.arg("auth")
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("login"))
.stdout(predicate::str::contains("logout"))
.stdout(predicate::str::contains("status"))
.stdout(predicate::str::contains("whoami"));
}
#[test]
fn auth_login_against_unreachable_url_errors() {
cmd()
.arg("auth")
.arg("login")
.arg("--url")
.arg("http://127.0.0.1:1")
.arg("--code")
.arg("BCDFGHJK")
.assert()
.failure()
.stderr(predicate::str::contains("127.0.0.1"));
}
#[test]
fn import_help_lists_sources_including_pathbase() {
cmd()
.args(["p", "import"])
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("git"))
.stdout(predicate::str::contains("github"))
.stdout(predicate::str::contains("claude"))
.stdout(predicate::str::contains("pathbase"));
}
fn copilot_home_fixture() -> (tempfile::TempDir, String) {
let home = tempfile::tempdir().unwrap();
let id = "demo-sess-01";
let dir = home.path().join("session-state").join(id);
std::fs::create_dir_all(&dir).unwrap();
let body = [
r#"{"type":"session.start","timestamp":"2026-06-30T10:00:00.000Z","data":{"copilotVersion":"1.0.66","producer":"copilot-agent","context":{"cwd":"/tmp/demo","gitRoot":"/tmp/demo","repository":"acme/demo","branch":"main"}}}"#,
r#"{"type":"user.message","timestamp":"2026-06-30T10:00:01.000Z","data":{"content":"hello copilot"}}"#,
r#"{"type":"assistant.turn_start","data":{}}"#,
r#"{"type":"assistant.message","timestamp":"2026-06-30T10:00:03.000Z","data":{"content":"hi there","model":"claude-haiku-4.5"}}"#,
r#"{"type":"assistant.turn_end","data":{}}"#,
]
.join("\n");
std::fs::write(dir.join("events.jsonl"), body).unwrap();
(home, id.to_string())
}
#[test]
fn import_help_lists_copilot() {
cmd()
.args(["p", "import", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("copilot"));
}
#[test]
fn import_copilot_writes_cache() {
let (home, id) = copilot_home_fixture();
let cfg = tempfile::tempdir().unwrap();
cmd()
.args(["p", "import", "copilot", "--session", &id])
.env("COPILOT_HOME", home.path())
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.assert()
.success()
.stdout(predicate::str::contains("copilot-path-copilot-"))
.stderr(predicate::str::contains("Imported"));
assert!(
cfg.path()
.join("documents/copilot-path-copilot-demo-ses.json")
.exists()
);
}
#[test]
fn list_copilot_tsv_shows_session() {
let (home, _id) = copilot_home_fixture();
cmd()
.args(["p", "list", "copilot", "--format", "tsv"])
.env("COPILOT_HOME", home.path())
.assert()
.success()
.stdout(predicate::str::contains("demo-sess-01"))
.stdout(predicate::str::contains("hello copilot"));
}
#[test]
fn show_copilot_renders_markdown() {
let (home, id) = copilot_home_fixture();
cmd()
.args(["show", "copilot", "--session", &id])
.env("COPILOT_HOME", home.path())
.assert()
.success()
.stdout(predicate::str::contains("Copilot session"))
.stdout(predicate::str::contains("hello copilot"));
}
#[test]
fn export_help_lists_copilot() {
cmd()
.args(["p", "export", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("copilot"));
}
#[test]
fn export_copilot_to_output_file() {
let (home, id) = copilot_home_fixture();
let tmp = tempfile::tempdir().unwrap();
let doc = tmp.path().join("doc.json");
let stdout = cmd()
.args(["p", "import", "copilot", "--session", &id, "--no-cache"])
.env("COPILOT_HOME", home.path())
.assert()
.success()
.get_output()
.stdout
.clone();
std::fs::write(&doc, stdout).unwrap();
let events = tmp.path().join("events.jsonl");
cmd()
.args(["p", "export", "copilot", "--input"])
.arg(&doc)
.arg("--output")
.arg(&events)
.assert()
.success()
.stderr(predicate::str::contains("events to"));
let jsonl = std::fs::read_to_string(&events).unwrap();
assert!(
jsonl.lines().next().unwrap().contains("\"session.start\""),
"first projected line should be session.start"
);
assert!(jsonl.contains("hello copilot"), "user prompt round-trips");
}
#[test]
fn export_help_lists_claude_and_pathbase() {
cmd()
.args(["p", "export"])
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("claude"))
.stdout(predicate::str::contains("pathbase"));
}
#[test]
fn import_git_no_cache_emits_stdout_json() {
let (dir, branch) = git_fixture();
cmd()
.args(["p", "import"])
.arg("git")
.arg("--no-cache")
.arg("--repo")
.arg(dir.path())
.arg("--branch")
.arg(&branch)
.assert()
.success()
.stdout(predicate::str::contains("\"graph\":"))
.stdout(predicate::str::contains("\"paths\":"))
.stdout(predicate::str::contains("\"steps\""));
}
#[test]
fn import_git_writes_cache_and_prints_path() {
let (dir, branch) = git_fixture();
let cfg = tempfile::tempdir().unwrap();
cmd()
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args(["p", "import"])
.arg("git")
.arg("--repo")
.arg(dir.path())
.arg("--branch")
.arg(&branch)
.assert()
.success()
.stdout(predicate::str::contains(".json"))
.stderr(predicate::str::contains("Imported"));
}
#[test]
fn import_git_errors_on_existing_cache_without_force() {
let (dir, branch) = git_fixture();
let cfg = tempfile::tempdir().unwrap();
cmd()
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args(["p", "import", "git", "--branch"])
.arg(&branch)
.arg("--repo")
.arg(dir.path())
.assert()
.success();
cmd()
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args(["p", "import", "git", "--branch"])
.arg(&branch)
.arg("--repo")
.arg(dir.path())
.assert()
.failure()
.stderr(predicate::str::contains("already exists"));
cmd()
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args(["p", "import", "git", "--force", "--branch"])
.arg(&branch)
.arg("--repo")
.arg(dir.path())
.assert()
.success();
}
#[test]
fn cache_ls_on_empty_directory_prints_hint() {
let cfg = tempfile::tempdir().unwrap();
cmd()
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args(["p", "cache", "ls"])
.assert()
.success()
.stderr(predicate::str::contains("No cached"));
}
#[test]
fn cache_ls_after_import_lists_entry() {
let (dir, branch) = git_fixture();
let cfg = tempfile::tempdir().unwrap();
cmd()
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args(["p", "import", "git", "--branch"])
.arg(&branch)
.arg("--repo")
.arg(dir.path())
.assert()
.success();
cmd()
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args(["p", "cache", "ls"])
.assert()
.success()
.stdout(predicate::str::contains("git-"));
}
fn claude_home_fixture() -> (tempfile::TempDir, PathBuf) {
let temp = tempfile::tempdir().unwrap();
let project = temp.path().join("proj");
std::fs::create_dir_all(&project).unwrap();
let project_slug = project
.to_string_lossy()
.replace([std::path::MAIN_SEPARATOR, '_', '.'], "-");
let project_dir = temp.path().join(".claude/projects").join(&project_slug);
std::fs::create_dir_all(&project_dir).unwrap();
let session_file = project_dir.join("session-abc.jsonl");
std::fs::write(
&session_file,
format!(
r#"{{"type":"user","uuid":"u-1","timestamp":"2024-01-01T00:00:00Z","cwd":"{cwd}","message":{{"role":"user","content":"hi"}}}}
{{"type":"assistant","uuid":"a-1","timestamp":"2024-01-01T00:00:01Z","message":{{"role":"assistant","content":"hello"}}}}
"#,
cwd = project.display()
),
)
.unwrap();
(temp, session_file)
}
#[test]
fn cache_sync_ingests_reskips_and_updates() {
let (home, session_file) = claude_home_fixture();
let cfg = tempfile::tempdir().unwrap();
let sync = || {
let mut c = cmd();
c.env("HOME", home.path())
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args(["p", "cache", "sync", "claude"]);
c
};
sync()
.assert()
.success()
.stderr(predicate::str::contains("1 new, 0 updated, 0 unchanged"));
let manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(cfg.path().join("manifest.json")).unwrap())
.unwrap();
let record = &manifest["claude"]["session-abc"];
let cache_id = record["cache_id"].as_str().unwrap();
assert!(
cfg.path()
.join(format!("documents/{cache_id}.json"))
.exists()
);
sync()
.assert()
.success()
.stderr(predicate::str::contains("0 new, 0 updated, 1 unchanged"));
let mut body = std::fs::read_to_string(&session_file).unwrap();
body.push_str(
r#"{"type":"user","uuid":"u-2","timestamp":"2024-01-01T00:05:00Z","cwd":"/x","message":{"role":"user","content":"more"}}"#,
);
body.push('\n');
std::fs::write(&session_file, body).unwrap();
sync()
.assert()
.success()
.stderr(predicate::str::contains("0 new, 1 updated, 0 unchanged"));
}
#[test]
fn cache_sync_default_run_with_no_sessions_reports_nothing() {
let home = tempfile::tempdir().unwrap();
let cfg = tempfile::tempdir().unwrap();
cmd()
.env("HOME", home.path())
.env_remove("XDG_DATA_HOME")
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args(["p", "cache", "sync"])
.assert()
.success()
.stderr(predicate::str::contains("nothing to sync"));
assert!(!cfg.path().join("manifest.json").exists());
}
#[test]
fn import_records_manifest_so_sync_skips() {
let (home, _session_file) = claude_home_fixture();
let cfg = tempfile::tempdir().unwrap();
let project = home.path().join("proj");
cmd()
.env("HOME", home.path())
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args([
"p",
"import",
"claude",
"--session",
"session-abc",
"--project",
])
.arg(&project)
.assert()
.success();
let manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(cfg.path().join("manifest.json")).unwrap())
.unwrap();
let record = &manifest["claude"]["session-abc"];
assert!(record["modified"].is_string(), "import must stamp mtime");
assert!(record["size"].is_u64(), "import must stamp size");
cmd()
.env("HOME", home.path())
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args(["p", "cache", "sync", "claude"])
.assert()
.success()
.stderr(predicate::str::contains("0 new, 0 updated, 1 unchanged"));
}
#[test]
fn bulk_import_records_every_session() {
let (home, session_file) = claude_home_fixture();
let cfg = tempfile::tempdir().unwrap();
let project = home.path().join("proj");
std::fs::write(
session_file.parent().unwrap().join("deadbeef-second.jsonl"),
format!(
r#"{{"type":"user","uuid":"u-9","timestamp":"2024-02-01T00:00:00Z","cwd":"{cwd}","message":{{"role":"user","content":"second"}}}}
"#,
cwd = project.display()
),
)
.unwrap();
cmd()
.env("HOME", home.path())
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args(["p", "import", "claude", "--all", "--project"])
.arg(&project)
.assert()
.success();
let manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(cfg.path().join("manifest.json")).unwrap())
.unwrap();
assert_eq!(
manifest["claude"].as_object().unwrap().len(),
2,
"--all must record every session it writes"
);
cmd()
.env("HOME", home.path())
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args(["p", "cache", "sync", "claude"])
.assert()
.success()
.stderr(predicate::str::contains("0 new, 0 updated, 2 unchanged"));
}
#[test]
fn git_import_lands_in_manifest_and_sync_leaves_it_alone() {
let (dir, branch) = git_fixture();
let cfg = tempfile::tempdir().unwrap();
cmd()
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args(["p", "import", "git", "--branch"])
.arg(&branch)
.arg("--repo")
.arg(dir.path())
.assert()
.success();
let manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(cfg.path().join("manifest.json")).unwrap())
.unwrap();
let git_records = manifest["git"].as_object().unwrap();
assert_eq!(git_records.len(), 1);
let record = git_records.values().next().unwrap();
assert!(
record["path"].is_string(),
"git records carry the repo path"
);
let home = tempfile::tempdir().unwrap();
cmd()
.env("HOME", home.path())
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args(["p", "cache", "sync", "git"])
.assert()
.success()
.stderr(predicate::str::contains("0 new, 0 updated, 0 unchanged"));
}
#[test]
fn share_records_manifest_so_sync_skips() {
let (port, server, _temp, project, home) = share_anon_fixture();
let cfg = tempfile::tempdir().unwrap();
cmd()
.env("HOME", &home)
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args([
"share",
"--harness",
"claude",
"--session",
"session-abc",
"--project",
])
.arg(&project)
.args(["--anon", "--url"])
.arg(format!("http://127.0.0.1:{port}"))
.assert()
.success();
server.join().unwrap();
let manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(cfg.path().join("manifest.json")).unwrap())
.unwrap();
assert!(manifest["claude"]["session-abc"]["modified"].is_string());
cmd()
.env("HOME", &home)
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args(["p", "cache", "sync", "claude"])
.assert()
.success()
.stderr(predicate::str::contains("0 new, 0 updated, 1 unchanged"));
}
#[test]
fn share_uploads_cached_doc_when_source_unchanged() {
let (home, session_file) = claude_home_fixture();
let cfg = tempfile::tempdir().unwrap();
let project = home.path().join("proj");
let share = |port: u16| {
let mut c = cmd();
c.env("HOME", home.path())
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args([
"share",
"--harness",
"claude",
"--session",
"session-abc",
"--project",
])
.arg(&project)
.args(["--anon", "--url"])
.arg(format!("http://127.0.0.1:{port}"));
c
};
let (port, server) = one_shot_anon_server();
share(port)
.assert()
.success()
.stderr(predicate::str::contains("uploading without re-deriving").not());
server.join().unwrap();
let (port, server) = one_shot_anon_server();
share(port)
.assert()
.success()
.stderr(predicate::str::contains("uploading without re-deriving"));
server.join().unwrap();
let mut body = std::fs::read_to_string(&session_file).unwrap();
body.push_str(
r#"{"type":"user","uuid":"u-2","timestamp":"2024-01-01T00:05:00Z","cwd":"/x","message":{"role":"user","content":"more"}}"#,
);
body.push('\n');
std::fs::write(&session_file, body).unwrap();
let (port, server) = one_shot_anon_server();
share(port)
.assert()
.success()
.stderr(predicate::str::contains("uploading without re-deriving").not())
.stderr(predicate::str::contains("Cached claude session"));
server.join().unwrap();
}
#[test]
fn import_ingests_thinking_maximally() {
let (home, session_file) = claude_home_fixture();
let cfg = tempfile::tempdir().unwrap();
let project = home.path().join("proj");
let mut body = std::fs::read_to_string(&session_file).unwrap();
body.push_str(
r#"{"type":"assistant","uuid":"a-2","timestamp":"2024-01-01T00:00:02Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"secret reasoning"},{"type":"text","text":"done"}]}}"#,
);
body.push('\n');
std::fs::write(&session_file, body).unwrap();
let out = cmd()
.env("HOME", home.path())
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args([
"p",
"import",
"claude",
"--session",
"session-abc",
"--project",
])
.arg(&project)
.output()
.unwrap();
assert!(out.status.success());
let doc_path = String::from_utf8_lossy(&out.stdout).trim().to_string();
let doc = std::fs::read_to_string(&doc_path).unwrap();
assert!(
doc.contains("secret reasoning"),
"the cache holds the maximal derivation, thinking included"
);
}
#[test]
fn import_after_query_sync_is_a_noop_not_an_error() {
let (home, _session_file) = claude_home_fixture();
let cfg = tempfile::tempdir().unwrap();
let project = home.path().join("proj");
cmd()
.env("HOME", home.path())
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args(["query", "--source", "claude", "length"])
.assert()
.success();
cmd()
.env("HOME", home.path())
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args([
"p",
"import",
"claude",
"--session",
"session-abc",
"--project",
])
.arg(&project)
.assert()
.success()
.stderr(predicate::str::contains("already up to date"));
}
#[cfg(unix)]
#[test]
fn bulk_import_skips_unreadable_sessions() {
use std::os::unix::fs::PermissionsExt;
let (home, session_file) = claude_home_fixture();
let cfg = tempfile::tempdir().unwrap();
let project = home.path().join("proj");
let bad = session_file.parent().unwrap().join("deadbeef-bad.jsonl");
std::fs::write(&bad, "x").unwrap();
std::fs::set_permissions(&bad, std::fs::Permissions::from_mode(0o000)).unwrap();
cmd()
.env("HOME", home.path())
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args(["p", "import", "claude", "--all", "--project"])
.arg(&project)
.assert()
.success()
.stderr(predicate::str::contains("Warning: skipping session"))
.stderr(predicate::str::contains("Imported"));
}
#[test]
fn cache_sync_rejects_unknown_type() {
let cfg = tempfile::tempdir().unwrap();
cmd()
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args(["p", "cache", "sync", "frobnicate"])
.assert()
.failure()
.stderr(predicate::str::contains("invalid value"));
}
#[test]
fn export_pathbase_repo_flag_requires_login() {
let cfg = tempfile::tempdir().unwrap();
cmd()
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args([
"p",
"export",
"pathbase",
"--repo",
"alex/pathstash",
"--url",
"http://127.0.0.1:1",
"--input",
])
.arg(examples_dir().join("path-01-pr.path.json"))
.assert()
.failure()
.stderr(predicate::str::contains("Not logged in"));
}
#[test]
fn import_pathbase_rejects_legacy_trace_id() {
let cfg = tempfile::tempdir().unwrap();
cmd()
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args(["p", "import", "pathbase", "trc_nonexistent"])
.assert()
.failure()
.stderr(predicate::str::contains("<owner>/<repo>/<uuid>"));
}
#[test]
fn import_git_no_cache_honors_global_pretty() {
let (dir, branch) = git_fixture();
let output = cmd()
.arg("--pretty")
.args(["p", "import"])
.arg("git")
.arg("--no-cache")
.arg("--repo")
.arg(dir.path())
.arg("--branch")
.arg(&branch)
.output()
.unwrap();
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
stdout.contains("\n "),
"expected pretty-printed JSON, got: {stdout}"
);
}
#[test]
fn import_git_two_repos_on_same_branch_have_distinct_cache_ids() {
let (dir_a, branch) = git_fixture();
let (dir_b, _) = git_fixture();
let cfg = tempfile::tempdir().unwrap();
cmd()
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args(["p", "import", "git", "--branch"])
.arg(&branch)
.arg("--repo")
.arg(dir_a.path())
.assert()
.success();
cmd()
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args(["p", "import", "git", "--branch"])
.arg(&branch)
.arg("--repo")
.arg(dir_b.path())
.assert()
.success();
let ls = cmd()
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args(["p", "cache", "ls"])
.output()
.unwrap();
let stdout = String::from_utf8(ls.stdout).unwrap();
let git_entries = stdout.lines().filter(|l| l.starts_with("git-")).count();
assert_eq!(
git_entries, 2,
"expected two distinct git- cache entries, got:\n{stdout}"
);
}
#[test]
fn p_derive_is_first_class_and_warns_no_one() {
let (dir, branch) = git_fixture();
cmd()
.args(["p", "derive", "git", "--repo"])
.arg(dir.path())
.arg("--branch")
.arg(&branch)
.assert()
.success()
.stdout(predicate::str::contains("\"graph\":"))
.stderr(predicate::str::is_empty());
}
#[test]
fn share_help_lists_unified_picker_flags() {
cmd()
.args(["share", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--harness"))
.stdout(predicate::str::contains("--session"))
.stdout(predicate::str::contains("--project"))
.stdout(predicate::str::contains("--anon"));
}
#[test]
fn share_explicit_args_uploads_via_anon() {
use std::io::Write;
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
use std::io::Read;
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let body = r#"{"id":"fe94b6f9-b0af-4cdd-b9ca-3c9a2a697537","repo_id":"00000000-0000-0000-0000-000000000002","toolpath_id":"tp-1","document":{"graph":{"id":"g"},"paths":[]},"path_count":0,"url":"https://example.test/anon/abc-123","visibility":"unlisted","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}"#;
let resp = format!(
"HTTP/1.1 201 Created\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n{}",
body.len(),
body
);
let _ = stream.write_all(resp.as_bytes());
});
let temp = tempfile::tempdir().unwrap();
let project = temp.path().join("proj");
std::fs::create_dir_all(&project).unwrap();
let claude_dir = temp.path().join(".claude");
let project_slug = project
.to_string_lossy()
.replace([std::path::MAIN_SEPARATOR, '_', '.'], "-");
let project_dir = claude_dir.join("projects").join(&project_slug);
std::fs::create_dir_all(&project_dir).unwrap();
std::fs::write(
project_dir.join("session-abc.jsonl"),
format!(
r#"{{"type":"user","uuid":"u-1","timestamp":"2024-01-01T00:00:00Z","cwd":"{cwd}","message":{{"role":"user","content":"hi"}}}}
{{"type":"assistant","uuid":"a-1","timestamp":"2024-01-01T00:00:01Z","message":{{"role":"assistant","content":"hello"}}}}
"#,
cwd = project.display()
),
)
.unwrap();
let cfg = tempfile::tempdir().unwrap();
cmd()
.env("HOME", temp.path())
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args([
"share",
"--harness",
"claude",
"--session",
"session-abc",
"--project",
])
.arg(&project)
.args(["--anon", "--no-cache", "--url"])
.arg(format!("http://127.0.0.1:{port}"))
.assert()
.success()
.stdout(predicate::str::contains(
"https://example.test/anon/abc-123",
))
.stderr(predicate::str::contains("Uploaded"));
server.join().unwrap();
}
fn share_anon_fixture() -> (
u16,
std::thread::JoinHandle<()>,
tempfile::TempDir,
PathBuf,
PathBuf,
) {
use std::io::{Read, Write};
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let body = r#"{"id":"fe94b6f9-b0af-4cdd-b9ca-3c9a2a697537","repo_id":"00000000-0000-0000-0000-000000000002","toolpath_id":"tp-1","document":{"graph":{"id":"g"},"paths":[]},"path_count":0,"url":"https://example.test/anon/abc","visibility":"unlisted","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}"#;
let resp = format!(
"HTTP/1.1 201 Created\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n{}",
body.len(),
body
);
let _ = stream.write_all(resp.as_bytes());
});
let temp = tempfile::tempdir().unwrap();
let project = temp.path().join("proj");
std::fs::create_dir_all(&project).unwrap();
let claude_dir = temp.path().join(".claude");
let project_slug = project
.to_string_lossy()
.replace([std::path::MAIN_SEPARATOR, '_', '.'], "-");
let project_dir = claude_dir.join("projects").join(&project_slug);
std::fs::create_dir_all(&project_dir).unwrap();
std::fs::write(
project_dir.join("session-abc.jsonl"),
format!(
r#"{{"type":"user","uuid":"u-1","timestamp":"2024-01-01T00:00:00Z","cwd":"{cwd}","message":{{"role":"user","content":"hi"}}}}
{{"type":"assistant","uuid":"a-1","timestamp":"2024-01-01T00:00:01Z","message":{{"role":"assistant","content":"hello"}}}}
"#,
cwd = project.display()
),
)
.unwrap();
let home = temp.path().to_path_buf();
(port, server, temp, project, home)
}
fn one_shot_anon_server() -> (u16, std::thread::JoinHandle<()>) {
use std::io::{Read, Write};
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf);
let body = r#"{"id":"fe94b6f9-b0af-4cdd-b9ca-3c9a2a697537","repo_id":"00000000-0000-0000-0000-000000000002","toolpath_id":"tp-1","document":{"graph":{"id":"g"},"paths":[]},"path_count":0,"url":"https://example.test/anon/abc","visibility":"unlisted","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}"#;
let resp = format!(
"HTTP/1.1 201 Created\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n{}",
body.len(),
body
);
let _ = stream.write_all(resp.as_bytes());
});
(port, server)
}
#[test]
fn share_rewrites_cache_when_session_has_grown() {
let temp = tempfile::tempdir().unwrap();
let project = temp.path().join("proj");
std::fs::create_dir_all(&project).unwrap();
let claude_dir = temp.path().join(".claude");
let project_slug = project
.to_string_lossy()
.replace([std::path::MAIN_SEPARATOR, '_', '.'], "-");
let project_dir = claude_dir.join("projects").join(&project_slug);
std::fs::create_dir_all(&project_dir).unwrap();
let session_file = project_dir.join("session-grow.jsonl");
let cwd_str = project.display().to_string();
let initial = format!(
r#"{{"type":"user","uuid":"u-1","timestamp":"2024-01-01T00:00:00Z","cwd":"{cwd_str}","message":{{"role":"user","content":"first"}}}}
{{"type":"assistant","uuid":"a-1","timestamp":"2024-01-01T00:00:01Z","message":{{"role":"assistant","content":"reply-1"}}}}
"#
);
std::fs::write(&session_file, &initial).unwrap();
let cfg = tempfile::tempdir().unwrap();
let home = temp.path();
let (port1, server1) = one_shot_anon_server();
cmd()
.env("HOME", home)
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args([
"share",
"--harness",
"claude",
"--session",
"session-grow",
"--project",
])
.arg(&project)
.args(["--anon", "--url"])
.arg(format!("http://127.0.0.1:{port1}"))
.assert()
.success();
server1.join().unwrap();
let docs = cfg.path().join("documents");
let cache_files: Vec<_> = std::fs::read_dir(&docs)
.unwrap()
.filter_map(|e| e.ok())
.collect();
assert_eq!(
cache_files.len(),
1,
"expected one cache entry after first share"
);
let cache_path = cache_files[0].path();
let cache_v1 = std::fs::read_to_string(&cache_path).unwrap();
assert!(
cache_v1.contains("reply-1"),
"v1 cache must contain reply-1"
);
assert!(
!cache_v1.contains("reply-2"),
"v1 cache must not contain reply-2 yet"
);
let mut grown = initial.clone();
grown.push_str(&format!(
r#"{{"type":"user","uuid":"u-2","timestamp":"2024-01-02T00:00:00Z","cwd":"{cwd_str}","message":{{"role":"user","content":"second"}}}}
{{"type":"assistant","uuid":"a-2","timestamp":"2024-01-02T00:00:01Z","message":{{"role":"assistant","content":"reply-2"}}}}
"#
));
std::fs::write(&session_file, &grown).unwrap();
let (port2, server2) = one_shot_anon_server();
cmd()
.env("HOME", home)
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args([
"share",
"--harness",
"claude",
"--session",
"session-grow",
"--project",
])
.arg(&project)
.args(["--anon", "--url"])
.arg(format!("http://127.0.0.1:{port2}"))
.assert()
.success();
server2.join().unwrap();
let cache_v2 = std::fs::read_to_string(&cache_path).unwrap();
assert!(
cache_v2.contains("reply-2"),
"v2 cache should contain the new turn, got: {cache_v2}"
);
assert_ne!(
cache_v1, cache_v2,
"cache file must be rewritten when the session has grown"
);
}
#[test]
fn share_writes_cache_by_default() {
let (port, server, _temp, project, home) = share_anon_fixture();
let cfg = tempfile::tempdir().unwrap();
cmd()
.env("HOME", &home)
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args([
"share",
"--harness",
"claude",
"--session",
"session-abc",
"--project",
])
.arg(&project)
.args(["--anon", "--url"])
.arg(format!("http://127.0.0.1:{port}"))
.assert()
.success();
let docs = cfg.path().join("documents");
let entries: Vec<_> = std::fs::read_dir(&docs)
.unwrap()
.filter_map(|e| e.ok())
.collect();
assert_eq!(
entries.len(),
1,
"expected exactly one cache entry, got {entries:?}"
);
let name = entries[0].file_name().to_string_lossy().into_owned();
assert!(
name.starts_with("claude-"),
"expected claude-* cache id, got {name}"
);
server.join().unwrap();
}
#[test]
fn share_no_cache_skips_write() {
let (port, server, _temp, project, home) = share_anon_fixture();
let cfg = tempfile::tempdir().unwrap();
cmd()
.env("HOME", &home)
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args([
"share",
"--harness",
"claude",
"--session",
"session-abc",
"--project",
])
.arg(&project)
.args(["--anon", "--no-cache", "--url"])
.arg(format!("http://127.0.0.1:{port}"))
.assert()
.success();
let docs = cfg.path().join("documents");
if docs.exists() {
let entries: Vec<_> = std::fs::read_dir(&docs)
.unwrap()
.filter_map(|e| e.ok())
.collect();
assert!(
entries.is_empty(),
"expected no cache entries with --no-cache, got {entries:?}"
);
}
server.join().unwrap();
}
#[test]
fn share_logged_out_anon_default() {
let (port, server, _temp, project, home) = share_anon_fixture();
let cfg = tempfile::tempdir().unwrap();
cmd()
.env("HOME", &home)
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args([
"share",
"--harness",
"claude",
"--session",
"session-abc",
"--project",
])
.arg(&project)
.args(["--no-cache", "--url"])
.arg(format!("http://127.0.0.1:{port}"))
.assert()
.success()
.stderr(predicate::str::contains("not logged in"))
.stderr(predicate::str::contains("uploading anonymously"));
server.join().unwrap();
}
#[test]
fn share_filters_by_project_with_no_matches_errors() {
let cfg = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let nonexistent = home.path().join("never");
cmd()
.env("HOME", home.path())
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args(["share", "--project"])
.arg(&nonexistent)
.assert()
.failure()
.stderr(predicate::str::contains(
"No agent sessions found in project",
));
}
#[test]
fn share_no_harness_non_tty_prints_recipe() {
let temp = tempfile::tempdir().unwrap();
let project = temp.path().join("proj");
std::fs::create_dir_all(&project).unwrap();
let claude_dir = temp.path().join(".claude");
let project_slug = project
.to_string_lossy()
.replace([std::path::MAIN_SEPARATOR, '_', '.'], "-");
let project_dir = claude_dir.join("projects").join(&project_slug);
std::fs::create_dir_all(&project_dir).unwrap();
std::fs::write(
project_dir.join("session-recipe.jsonl"),
format!(
r#"{{"type":"user","uuid":"u-1","timestamp":"2024-01-01T00:00:00Z","cwd":"{cwd}","message":{{"role":"user","content":"hi"}}}}
{{"type":"assistant","uuid":"a-1","timestamp":"2024-01-01T00:00:01Z","message":{{"role":"assistant","content":"hello"}}}}
"#,
cwd = project.display()
),
)
.unwrap();
let cfg = tempfile::tempdir().unwrap();
cmd()
.env("HOME", temp.path())
.env("TOOLPATH_CONFIG_DIR", cfg.path())
.args(["share"])
.assert()
.failure()
.stderr(predicate::str::contains("path import"))
.stderr(predicate::str::contains("path export pathbase"));
}