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("dead-ends")
.arg("--input")
.arg(examples_dir().join("path-01-pr.path.json"))
.assert()
.success()
.stdout(predicate::str::contains("step-002a"));
}
#[test]
fn query_ancestors() {
cmd()
.arg("query")
.arg("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("dead-ends")
.arg("--input")
.arg(examples_dir().join("path-04-exploration.path.jsonl"))
.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"));
}
#[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-"));
}
#[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"));
}