mod common;
use beads_rust::model::{Issue, IssueType, Priority, Status};
use beads_rust::storage::SqliteStorage;
use chrono::Utc;
use common::cli::{
BrWorkspace, extract_json_payload, parse_list_issues, run_br, run_br_smoke_at_root_with_env,
};
use common::isolated_workspace_failure_fixture;
use serde_json::Value;
use std::fs;
use std::thread::sleep;
use std::time::Duration;
fn parse_created_id(stdout: &str) -> String {
let line = stdout.lines().next().unwrap_or("");
let normalized = line
.strip_prefix("✓ ")
.or_else(|| line.strip_prefix("✗ "))
.unwrap_or(line);
let id_part = normalized
.strip_prefix("Created ")
.and_then(|rest| rest.split(':').next())
.unwrap_or("");
id_part.trim().to_string()
}
fn make_issue(id: &str, title: &str, now: chrono::DateTime<Utc>) -> Issue {
Issue {
id: id.to_string(),
title: title.to_string(),
status: Status::Open,
priority: Priority::MEDIUM,
issue_type: IssueType::Task,
created_at: now,
updated_at: now,
content_hash: None,
description: None,
design: None,
acceptance_criteria: None,
notes: None,
assignee: None,
owner: None,
estimated_minutes: None,
created_by: None,
closed_at: None,
close_reason: None,
closed_by_session: None,
due_at: None,
defer_until: None,
external_ref: None,
source_system: None,
source_repo: None,
deleted_at: None,
deleted_by: None,
delete_reason: None,
original_type: None,
compaction_level: None,
compacted_at: None,
compacted_at_commit: None,
original_size: None,
sender: None,
ephemeral: false,
pinned: false,
is_template: false,
labels: vec![],
dependencies: vec![],
comments: vec![],
}
}
#[test]
fn e2e_basic_lifecycle() {
let _log = common::test_log("e2e_basic_lifecycle");
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create = run_br(&workspace, ["create", "Test issue"], "create");
assert!(create.status.success(), "create failed: {}", create.stderr);
let id = parse_created_id(&create.stdout);
assert!(!id.is_empty(), "missing created id");
let update_args = vec![
"update".to_string(),
id.clone(),
"--status".to_string(),
"in_progress".to_string(),
"--priority".to_string(),
"1".to_string(),
"--assignee".to_string(),
"alice".to_string(),
];
let update = run_br(&workspace, update_args, "update");
assert!(update.status.success(), "update failed: {}", update.stderr);
let list = run_br(&workspace, ["list", "--json"], "list");
assert!(list.status.success(), "list failed: {}", list.stderr);
let list_json = parse_list_issues(&list.stdout);
assert!(
list_json
.iter()
.any(|item| item["id"] == id && item["status"] == "in_progress"),
"updated issue not found in list"
);
let list_text = run_br(&workspace, ["list"], "list_text");
assert!(
list_text.status.success(),
"list text failed: {}",
list_text.stderr
);
assert!(
list_text.stdout.contains("Test issue"),
"list text missing issue title"
);
let show = run_br(&workspace, ["show", &id, "--json"], "show");
assert!(show.status.success(), "show failed: {}", show.stderr);
let show_payload = extract_json_payload(&show.stdout);
let show_json: Vec<Value> = serde_json::from_str(&show_payload).expect("show json");
assert_eq!(show_json[0]["id"], id);
let show_text = run_br(&workspace, ["show", &id], "show_text");
assert!(
show_text.status.success(),
"show text failed: {}",
show_text.stderr
);
assert!(
show_text.stdout.contains("Test issue"),
"show text missing title"
);
let close_args = vec![
"update".to_string(),
id,
"--status".to_string(),
"closed".to_string(),
];
let close = run_br(&workspace, close_args, "close");
assert!(close.status.success(), "close failed: {}", close.stderr);
}
#[test]
fn e2e_non_hermetic_smoke_existing_workspace_preserves_env_sensitive_paths() {
let _log =
common::test_log("e2e_non_hermetic_smoke_existing_workspace_preserves_env_sensitive_paths");
let fixture = isolated_workspace_failure_fixture("metadata_custom_paths")
.expect("metadata_custom_paths fixture");
let runner_root = fixture.root.join("ambient-env-smoke");
fs::create_dir_all(&runner_root).expect("create smoke runner root");
let external_beads_dir = fixture.root.join(".beads");
let external_beads_dir_str = external_beads_dir.display().to_string();
let custom_db_str = external_beads_dir.join("custom.db").display().to_string();
let custom_jsonl_str = external_beads_dir
.join("custom.jsonl")
.display()
.to_string();
let smoke_env = || {
vec![
("BEADS_DIR".to_string(), external_beads_dir_str.clone()),
("BR_OUTPUT_FORMAT".to_string(), "json".to_string()),
]
};
let where_cmd = run_br_smoke_at_root_with_env(
&runner_root,
["where"],
smoke_env(),
"non_hermetic_where_existing_workspace",
);
assert!(
where_cmd.status.success(),
"where smoke failed: {}",
where_cmd.stderr
);
let where_json: Value =
serde_json::from_str(&extract_json_payload(&where_cmd.stdout)).expect("where smoke json");
assert_eq!(
where_json["path"].as_str(),
Some(external_beads_dir_str.as_str())
);
assert_eq!(
where_json["database_path"].as_str(),
Some(custom_db_str.as_str())
);
assert_eq!(
where_json["jsonl_path"].as_str(),
Some(custom_jsonl_str.as_str())
);
let info_cmd = run_br_smoke_at_root_with_env(
&runner_root,
["info"],
smoke_env(),
"non_hermetic_info_existing_workspace",
);
assert!(
info_cmd.status.success(),
"info smoke failed: {}",
info_cmd.stderr
);
let info_json: Value =
serde_json::from_str(&extract_json_payload(&info_cmd.stdout)).expect("info smoke json");
assert_eq!(
info_json["beads_dir"].as_str(),
Some(external_beads_dir_str.as_str())
);
assert_eq!(
info_json["database_path"].as_str(),
Some(custom_db_str.as_str())
);
assert_eq!(
info_json["jsonl_path"].as_str(),
Some(custom_jsonl_str.as_str())
);
assert!(
info_json["issue_count"].as_u64().is_some(),
"info smoke should report issue_count: {info_json}"
);
let sync_status_cmd = run_br_smoke_at_root_with_env(
&runner_root,
["sync", "--status"],
smoke_env(),
"non_hermetic_sync_status_existing_workspace",
);
assert!(
sync_status_cmd.status.success(),
"sync --status smoke failed: {}",
sync_status_cmd.stderr
);
let sync_status_json: Value =
serde_json::from_str(&extract_json_payload(&sync_status_cmd.stdout))
.expect("sync status smoke json");
assert_eq!(sync_status_json["jsonl_exists"].as_bool(), Some(true));
}
#[test]
fn e2e_update_claim_multiple_ids_is_all_or_nothing() {
let _log = common::test_log("e2e_update_claim_multiple_ids_is_all_or_nothing");
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init_claim_multiple_ids");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create_first = run_br(
&workspace,
["create", "First claim target", "--json"],
"create_first_claim_target",
);
assert!(
create_first.status.success(),
"first create failed: {}",
create_first.stderr
);
let first_issue: Value = serde_json::from_str(&extract_json_payload(&create_first.stdout))
.expect("first create json");
let first_id = first_issue["id"]
.as_str()
.expect("first issue id")
.to_string();
let create_second = run_br(
&workspace,
["create", "Second claim target", "--json"],
"create_second_claim_target",
);
assert!(
create_second.status.success(),
"second create failed: {}",
create_second.stderr
);
let second_issue: Value = serde_json::from_str(&extract_json_payload(&create_second.stdout))
.expect("second create json");
let second_id = second_issue["id"]
.as_str()
.expect("second issue id")
.to_string();
let claim_second = run_br(
&workspace,
["--actor", "bob", "update", &second_id, "--claim", "--json"],
"claim_second_issue_bob",
);
assert!(
claim_second.status.success(),
"claim second failed: {}",
claim_second.stderr
);
let claim_both = run_br(
&workspace,
[
"--actor", "alice", "update", &first_id, &second_id, "--claim", "--json",
],
"claim_multiple_ids_atomic",
);
assert!(
!claim_both.status.success(),
"expected multi-id claim to fail when one issue is already assigned"
);
let show_first = run_br(
&workspace,
["show", &first_id, "--json"],
"show_first_after_failed_multi_claim",
);
assert!(
show_first.status.success(),
"show first failed: {}",
show_first.stderr
);
let first_after: Vec<Value> =
serde_json::from_str(&extract_json_payload(&show_first.stdout)).expect("show first json");
assert_eq!(first_after[0]["status"].as_str(), Some("open"));
assert!(first_after[0]["assignee"].is_null());
let show_second = run_br(
&workspace,
["show", &second_id, "--json"],
"show_second_after_failed_multi_claim",
);
assert!(
show_second.status.success(),
"show second failed: {}",
show_second.stderr
);
let second_after: Vec<Value> =
serde_json::from_str(&extract_json_payload(&show_second.stdout)).expect("show second json");
assert_eq!(second_after[0]["status"].as_str(), Some("in_progress"));
assert_eq!(second_after[0]["assignee"].as_str(), Some("bob"));
}
#[test]
fn e2e_create_updates_last_touched_context() {
let _log = common::test_log("e2e_create_updates_last_touched_context");
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init_create_last_touched");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create = run_br(
&workspace,
["create", "Create updates last touched"],
"create_last_touched",
);
assert!(create.status.success(), "create failed: {}", create.stderr);
let created_id = parse_created_id(&create.stdout);
assert!(!created_id.is_empty(), "missing created id");
let update = run_br(
&workspace,
["update", "--status", "in_progress"],
"update_last_touched_after_create",
);
assert!(update.status.success(), "update failed: {}", update.stderr);
let show = run_br(
&workspace,
["show", &created_id, "--json"],
"show_last_touched_after_create",
);
assert!(show.status.success(), "show failed: {}", show.stderr);
let payload = extract_json_payload(&show.stdout);
let json: Vec<Value> = serde_json::from_str(&payload).expect("show json");
assert_eq!(json[0]["status"], "in_progress");
}
#[test]
fn e2e_create_dry_run_does_not_update_last_touched_context() {
let _log = common::test_log("e2e_create_dry_run_does_not_update_last_touched_context");
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init_create_dry_run_last_touched");
assert!(init.status.success(), "init failed: {}", init.stderr);
let seed = run_br(
&workspace,
["create", "Seed for dry-run last touched"],
"seed_create_dry_run_last_touched",
);
assert!(seed.status.success(), "seed create failed: {}", seed.stderr);
let seed_id = parse_created_id(&seed.stdout);
assert!(!seed_id.is_empty(), "missing seed id");
let dry_run = run_br(
&workspace,
[
"create",
"Dry-run should not move last touched",
"--dry-run",
],
"create_dry_run_last_touched",
);
assert!(
dry_run.status.success(),
"dry-run create failed: {}",
dry_run.stderr
);
let update = run_br(
&workspace,
["update", "--status", "in_progress"],
"update_after_create_dry_run",
);
assert!(update.status.success(), "update failed: {}", update.stderr);
let show = run_br(
&workspace,
["show", &seed_id, "--json"],
"show_after_create_dry_run",
);
assert!(show.status.success(), "show failed: {}", show.stderr);
let payload = extract_json_payload(&show.stdout);
let json: Vec<Value> = serde_json::from_str(&payload).expect("show json");
assert_eq!(json[0]["status"], "in_progress");
}
#[test]
fn e2e_no_db_create_updates_last_touched_after_flush() {
let _log = common::test_log("e2e_no_db_create_updates_last_touched_after_flush");
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init_no_db_create_last_touched");
assert!(init.status.success(), "init failed: {}", init.stderr);
let seed = run_br(
&workspace,
["create", "Seed issue"],
"seed_no_db_create_last_touched",
);
assert!(seed.status.success(), "seed create failed: {}", seed.stderr);
let sync = run_br(
&workspace,
["sync", "--flush-only"],
"sync_no_db_create_last_touched",
);
assert!(sync.status.success(), "sync failed: {}", sync.stderr);
let create = run_br(
&workspace,
["--no-db", "create", "No DB create updates last touched"],
"create_no_db_last_touched",
);
assert!(
create.status.success(),
"no-db create failed: {}",
create.stderr
);
let created_id = parse_created_id(&create.stdout);
assert!(!created_id.is_empty(), "missing created id");
let update = run_br(
&workspace,
["update", "--status", "in_progress"],
"update_last_touched_after_no_db_create",
);
assert!(update.status.success(), "update failed: {}", update.stderr);
let show = run_br(
&workspace,
["show", &created_id, "--json"],
"show_last_touched_after_no_db_create",
);
assert!(show.status.success(), "show failed: {}", show.stderr);
let payload = extract_json_payload(&show.stdout);
let json: Vec<Value> = serde_json::from_str(&payload).expect("show json");
assert_eq!(json[0]["status"], "in_progress");
}
#[test]
fn e2e_quick_capture() {
let _log = common::test_log("e2e_quick_capture");
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let quick = run_br(&workspace, ["q", "Quick", "issue"], "quick");
assert!(quick.status.success(), "quick failed: {}", quick.stderr);
let quick_id = quick.stdout.lines().next().unwrap_or("").trim().to_string();
assert!(!quick_id.is_empty(), "missing quick id");
assert!(quick_id.contains('-'), "unexpected quick id format");
}
#[test]
fn e2e_sync_roundtrip() {
let _log = common::test_log("e2e_sync_roundtrip");
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create = run_br(
&workspace,
["create", "Original title", "--no-auto-flush"],
"create",
);
assert!(create.status.success(), "create failed: {}", create.stderr);
let id = parse_created_id(&create.stdout);
assert!(!id.is_empty(), "missing created id");
let sync = run_br(&workspace, ["sync", "--flush-only"], "sync_flush");
assert!(sync.status.success(), "sync flush failed: {}", sync.stderr);
assert!(
sync.stdout.contains("Exported"),
"sync flush text missing export message"
);
let jsonl_path = workspace.root.join(".beads").join("issues.jsonl");
assert!(jsonl_path.exists(), "issues.jsonl missing after flush");
let contents = fs::read_to_string(&jsonl_path).expect("read jsonl");
let mut updated_lines = Vec::new();
for line in contents.lines() {
if line.trim().is_empty() {
continue;
}
let mut issue: Value = serde_json::from_str(line).expect("parse issue");
if issue["title"] == "Original title" {
issue["title"] = Value::String("Modified title".to_string());
issue["updated_at"] = Value::String(Utc::now().to_rfc3339());
}
updated_lines.push(serde_json::to_string(&issue).expect("serialize issue"));
}
fs::write(&jsonl_path, updated_lines.join("\n") + "\n").expect("write jsonl");
let expected_jsonl = fs::read(&jsonl_path).expect("read edited jsonl bytes");
sleep(Duration::from_millis(50));
let sync_import = run_br(&workspace, ["sync", "--import-only"], "sync_import");
assert!(
sync_import.status.success(),
"sync import failed: {}",
sync_import.stderr
);
let post_import_jsonl = fs::read(&jsonl_path).expect("read jsonl after import");
assert_eq!(
post_import_jsonl, expected_jsonl,
"sync --import-only must not rewrite issues.jsonl"
);
let show = run_br(&workspace, ["show", &id, "--json"], "show_after_import");
assert!(show.status.success(), "show failed: {}", show.stderr);
let payload = extract_json_payload(&show.stdout);
let show_json: Vec<Value> = serde_json::from_str(&payload).expect("show json");
assert_eq!(show_json[0]["title"], "Modified title");
}
#[test]
fn e2e_sync_import_staleness_and_force() {
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create = run_br(&workspace, ["create", "Stale issue"], "create");
assert!(create.status.success(), "create failed: {}", create.stderr);
let flush = run_br(&workspace, ["sync", "--flush-only"], "sync_flush_stale");
assert!(
flush.status.success(),
"sync flush failed: {}",
flush.stderr
);
let import_first = run_br(&workspace, ["sync", "--import-only"], "sync_import_first");
assert!(
import_first.status.success(),
"sync import first failed: {}",
import_first.stderr
);
let import_skip = run_br(&workspace, ["sync", "--import-only"], "sync_import_skip");
assert!(
import_skip.status.success(),
"sync import skip failed: {}",
import_skip.stderr
);
assert!(
import_skip
.stdout
.contains("JSONL is current (hash unchanged since last import)"),
"sync import skip missing current message"
);
let import_force = run_br(
&workspace,
["sync", "--import-only", "--force"],
"sync_import_force",
);
assert!(
import_force.status.success(),
"sync import force failed: {}",
import_force.stderr
);
assert!(
import_force.stdout.contains("Imported from JSONL"),
"sync import force missing header"
);
assert!(
import_force.stdout.contains("Processed: 1 issues"),
"sync import force missing processed count"
);
}
#[test]
fn e2e_no_db_read_write() {
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create = run_br(&workspace, ["create", "Seed issue"], "create_seed");
assert!(create.status.success(), "create failed: {}", create.stderr);
let sync = run_br(&workspace, ["sync", "--flush-only"], "sync_flush");
assert!(sync.status.success(), "sync flush failed: {}", sync.stderr);
let jsonl_path = workspace.root.join(".beads").join("issues.jsonl");
assert!(jsonl_path.exists(), "issues.jsonl missing");
let contents = fs::read_to_string(&jsonl_path).expect("read jsonl");
let mut issues: Vec<Value> = contents
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str(line).expect("parse jsonl"))
.collect();
assert!(!issues.is_empty(), "seed jsonl empty");
let now = Utc::now().to_rfc3339();
let mut injected = issues[0].clone();
injected["id"] = Value::String("bd-nodb1".to_string());
injected["title"] = Value::String("Injected no-db".to_string());
injected["created_at"] = Value::String(now.clone());
injected["updated_at"] = Value::String(now);
issues.push(injected);
let rewritten: Vec<String> = issues
.into_iter()
.map(|issue| serde_json::to_string(&issue).expect("serialize jsonl"))
.collect();
fs::write(&jsonl_path, rewritten.join("\n") + "\n").expect("write jsonl");
let list = run_br(&workspace, ["--no-db", "list", "--json"], "list_no_db");
assert!(
list.status.success(),
"list --no-db failed: {}",
list.stderr
);
let list_json = parse_list_issues(&list.stdout);
assert!(
list_json.iter().any(|item| item["id"] == "bd-nodb1"),
"no-db list missing injected issue"
);
let create_no_db = run_br(
&workspace,
["--no-db", "create", "No DB create"],
"create_no_db",
);
assert!(
create_no_db.status.success(),
"create --no-db failed: {}",
create_no_db.stderr
);
let created_id = parse_created_id(&create_no_db.stdout);
assert!(!created_id.is_empty(), "no-db create missing id");
let updated = fs::read_to_string(&jsonl_path).expect("read jsonl after no-db");
assert!(
updated.contains("No DB create"),
"no-db create did not update JSONL"
);
}
#[test]
fn e2e_no_db_mixed_prefixes_are_supported() {
let workspace = BrWorkspace::new();
let beads_dir = workspace.root.join(".beads");
fs::create_dir_all(&beads_dir).expect("create .beads");
let jsonl_path = beads_dir.join("issues.jsonl");
let now = Utc::now();
let issue_a = make_issue("aa-abc", "Alpha issue", now);
let issue_b = make_issue("bb-def", "Beta issue", now);
let lines = [
serde_json::to_string(&issue_a).expect("serialize issue a"),
serde_json::to_string(&issue_b).expect("serialize issue b"),
];
fs::write(&jsonl_path, lines.join("\n") + "\n").expect("write jsonl");
let list = run_br(
&workspace,
["--no-db", "list", "--json"],
"list_no_db_mixed",
);
assert!(
list.status.success(),
"list --no-db should accept mixed prefixes: {}",
list.stderr
);
let issues = parse_list_issues(&list.stdout);
let ids: Vec<&str> = issues
.iter()
.filter_map(|issue| issue["id"].as_str())
.collect();
assert!(ids.contains(&"aa-abc"), "expected aa-abc in {ids:?}");
assert!(ids.contains(&"bb-def"), "expected bb-def in {ids:?}");
}
#[test]
fn e2e_no_db_mutations_succeed_with_large_export_hash_batches() {
let _log = common::test_log("e2e_no_db_mutations_succeed_with_large_export_hash_batches");
let workspace = BrWorkspace::new();
let beads_dir = workspace.root.join(".beads");
fs::create_dir_all(&beads_dir).expect("create .beads");
let jsonl_path = beads_dir.join("issues.jsonl");
let now = Utc::now();
let seed_records: Vec<String> = (0..33)
.map(|idx| {
serde_json::to_string(&make_issue(
&format!("bd-a{idx:02}"),
&format!("Seed issue {idx}"),
now,
))
.expect("serialize seed issue")
})
.collect();
fs::write(&jsonl_path, seed_records.join("\n") + "\n").expect("write seed jsonl");
let create = run_br(
&workspace,
["--no-db", "create", "Large no-db create"],
"create_no_db_large_hash_batch",
);
assert!(
create.status.success(),
"create --no-db should succeed when export_hashes rewrite spans many rows: {}",
create.stderr
);
let created_id = parse_created_id(&create.stdout);
assert!(
!created_id.is_empty(),
"missing created id after no-db create"
);
let add_comment = run_br(
&workspace,
[
"--no-db",
"comments",
"add",
&created_id,
"Large no-db comment",
"--json",
],
"comment_no_db_large_hash_batch",
);
assert!(
add_comment.status.success(),
"comments add --no-db should succeed after large export_hash rewrite: {}",
add_comment.stderr
);
let add_dependency = run_br(
&workspace,
["--no-db", "dep", "add", &created_id, "bd-a00", "--json"],
"dep_add_no_db_large_hash_batch",
);
assert!(
add_dependency.status.success(),
"dep add --no-db should succeed after large export_hash rewrite: {}",
add_dependency.stderr
);
let created_record = fs::read_to_string(&jsonl_path)
.expect("read issues.jsonl")
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| serde_json::from_str::<Value>(line).expect("parse issue json"))
.find(|record| record["id"].as_str() == Some(created_id.as_str()))
.expect("created issue record in issues.jsonl");
assert_eq!(created_record["title"], "Large no-db create");
assert!(
created_record["comments"]
.as_array()
.is_some_and(|comments| comments
.iter()
.any(|comment| { comment["text"].as_str() == Some("Large no-db comment") })),
"created issue should retain the no-db comment mutation"
);
assert!(
created_record["dependencies"]
.as_array()
.is_some_and(|dependencies| dependencies
.iter()
.any(|dependency| { dependency["depends_on_id"].as_str() == Some("bd-a00") })),
"created issue should retain the no-db dependency mutation"
);
}
#[test]
fn e2e_sync_flush_only_succeeds_with_large_mixed_prefix_export_hash_rewrite() {
let _log = common::test_log(
"e2e_sync_flush_only_succeeds_with_large_mixed_prefix_export_hash_rewrite",
);
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let db_path = workspace.root.join(".beads").join("beads.db");
let mut storage = SqliteStorage::open(&db_path).expect("open workspace db");
let now = Utc::now();
let seeded_hashes: Vec<(String, String)> = (0..160)
.map(|idx| {
let prefix = if idx % 2 == 0 { "bd" } else { "br" };
let issue_id = format!("{prefix}-sync-{idx:03}");
let issue = make_issue(&issue_id, &format!("Seed issue {idx}"), now);
storage.create_issue(&issue, "tester").expect("seed issue");
(issue_id, format!("seed-hash-{idx:03}"))
})
.collect();
storage
.set_export_hashes(&seeded_hashes)
.expect("seed export hashes");
let flush = run_br(
&workspace,
["sync", "--flush-only", "--no-auto-import"],
"sync_flush_large_mixed_export_hash_rewrite",
);
assert!(
flush.status.success(),
"sync --flush-only should succeed when rewriting many existing mixed-prefix export hashes: {}",
flush.stderr
);
let jsonl_path = workspace.root.join(".beads").join("issues.jsonl");
let exported_count = fs::read_to_string(&jsonl_path)
.expect("read issues.jsonl")
.lines()
.filter(|line| !line.trim().is_empty())
.count();
assert_eq!(exported_count, seeded_hashes.len());
}
#[test]
fn e2e_sync_manifest() {
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create = run_br(
&workspace,
["create", "Manifest issue", "--no-auto-flush"],
"create",
);
assert!(create.status.success(), "create failed: {}", create.stderr);
let sync = run_br(
&workspace,
["sync", "--flush-only", "--manifest"],
"sync_manifest",
);
assert!(
sync.status.success(),
"sync manifest failed: {}",
sync.stderr
);
let manifest_path = workspace.root.join(".beads").join(".manifest.json");
assert!(manifest_path.exists(), "manifest not created");
}
#[test]
fn e2e_sync_status_json() {
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create = run_br(&workspace, ["create", "Status issue"], "create");
assert!(create.status.success(), "create failed: {}", create.stderr);
let status = run_br(&workspace, ["sync", "--status", "--json"], "sync_status");
assert!(
status.status.success(),
"sync status failed: {}",
status.stderr
);
let payload = extract_json_payload(&status.stdout);
let status_json: Value = serde_json::from_str(&payload).expect("sync status json");
assert!(status_json["dirty_count"].is_number());
}
#[test]
fn e2e_version_text() {
let workspace = BrWorkspace::new();
let version = run_br(&workspace, ["version"], "version");
assert!(
version.status.success(),
"version failed: {}",
version.stderr
);
assert!(
version.stdout.contains("br version"),
"version output missing header"
);
}
#[test]
fn e2e_doctor_json() {
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let doctor = run_br(&workspace, ["doctor", "--json"], "doctor_json");
assert!(doctor.status.success(), "doctor failed: {}", doctor.stderr);
let payload = extract_json_payload(&doctor.stdout);
let doctor_json: Value = serde_json::from_str(&payload).expect("doctor json");
assert!(doctor_json["checks"].is_array(), "doctor checks missing");
}
#[test]
fn e2e_sync_status_text() {
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let status = run_br(&workspace, ["sync", "--status"], "sync_status_text");
assert!(
status.status.success(),
"sync status text failed: {}",
status.stderr
);
assert!(
status.stdout.contains("Sync Status"),
"sync status text missing header"
);
}
#[test]
fn e2e_version_json() {
let workspace = BrWorkspace::new();
let version = run_br(&workspace, ["version", "--json"], "version_json");
assert!(
version.status.success(),
"version json failed: {}",
version.stderr
);
let payload = extract_json_payload(&version.stdout);
let version_json: Value = serde_json::from_str(&payload).expect("version json");
assert!(version_json["version"].is_string());
}
#[test]
fn e2e_sync_conflict_markers_aborts_import() {
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create = run_br(&workspace, ["create", "Test issue"], "create");
assert!(create.status.success(), "create failed: {}", create.stderr);
let flush = run_br(&workspace, ["sync", "--flush-only"], "sync_flush");
assert!(
flush.status.success(),
"sync flush failed: {}",
flush.stderr
);
let jsonl_path = workspace.root.join(".beads").join("issues.jsonl");
let original = fs::read_to_string(&jsonl_path).expect("read jsonl");
let conflicted = format!(
"<<<<<<< HEAD\n{}\n=======\n{}\n>>>>>>> feature-branch\n",
original.trim(),
original.trim()
);
fs::write(&jsonl_path, conflicted).expect("write conflicted jsonl");
let import = run_br(
&workspace,
["sync", "--import-only", "--force"],
"sync_import_conflict",
);
assert!(
!import.status.success(),
"import should fail with conflict markers"
);
assert!(
import.stderr.contains("Merge conflict markers detected")
|| import.stdout.contains("Merge conflict markers detected"),
"error message should mention conflict markers: stdout={}, stderr={}",
import.stdout,
import.stderr
);
}
#[test]
fn e2e_sync_tombstone_preservation() {
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create = run_br(&workspace, ["create", "Issue to delete"], "create");
assert!(create.status.success(), "create failed: {}", create.stderr);
let id = parse_created_id(&create.stdout);
let delete = run_br(
&workspace,
["delete", &id, "--force", "--reason", "Testing tombstone"],
"delete",
);
assert!(delete.status.success(), "delete failed: {}", delete.stderr);
let show = run_br(&workspace, ["show", &id, "--json"], "show_tombstone");
assert!(
show.status.success(),
"show tombstone failed: {}",
show.stderr
);
let payload = extract_json_payload(&show.stdout);
let show_json: Vec<Value> = serde_json::from_str(&payload).expect("show json");
assert_eq!(
show_json[0]["status"], "tombstone",
"issue should be tombstone"
);
let flush = run_br(&workspace, ["sync", "--flush-only"], "sync_flush");
assert!(
flush.status.success(),
"sync flush failed: {}",
flush.stderr
);
let jsonl_path = workspace.root.join(".beads").join("issues.jsonl");
let contents = fs::read_to_string(&jsonl_path).expect("read jsonl");
assert!(
contents.contains("\"status\":\"tombstone\""),
"JSONL should contain tombstone status"
);
let workspace2 = BrWorkspace::new();
let init2 = run_br(&workspace2, ["init"], "init2");
assert!(init2.status.success(), "init2 failed: {}", init2.stderr);
let jsonl_path2 = workspace2.root.join(".beads").join("issues.jsonl");
fs::copy(&jsonl_path, &jsonl_path2).expect("copy jsonl");
let import = run_br(
&workspace2,
["sync", "--import-only", "--force"],
"sync_import",
);
assert!(import.status.success(), "import failed: {}", import.stderr);
let show2 = run_br(&workspace2, ["show", &id, "--json"], "show_after_import");
assert!(
show2.status.success(),
"show after import failed: {}",
show2.stderr
);
let payload2 = extract_json_payload(&show2.stdout);
let show_json2: Vec<Value> = serde_json::from_str(&payload2).expect("show json after import");
assert_eq!(
show_json2[0]["status"], "tombstone",
"tombstone should be preserved after import"
);
}
#[test]
fn e2e_sync_tombstone_protection() {
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create = run_br(&workspace, ["create", "Protected issue"], "create");
assert!(create.status.success(), "create failed: {}", create.stderr);
let id = parse_created_id(&create.stdout);
let delete = run_br(
&workspace,
["delete", &id, "--force", "--reason", "Tombstone test"],
"delete",
);
assert!(delete.status.success(), "delete failed: {}", delete.stderr);
let flush = run_br(&workspace, ["sync", "--flush-only"], "sync_flush");
assert!(
flush.status.success(),
"sync flush failed: {}",
flush.stderr
);
let jsonl_path = workspace.root.join(".beads").join("issues.jsonl");
let contents = fs::read_to_string(&jsonl_path).expect("read jsonl");
let mut modified_lines = Vec::new();
for line in contents.lines() {
if line.trim().is_empty() {
continue;
}
let mut issue: Value = serde_json::from_str(line).expect("parse issue");
if issue["status"] == "tombstone" {
issue["status"] = Value::String("open".to_string());
issue["updated_at"] = Value::String(Utc::now().to_rfc3339());
}
modified_lines.push(serde_json::to_string(&issue).expect("serialize"));
}
fs::write(&jsonl_path, modified_lines.join("\n") + "\n").expect("write modified jsonl");
sleep(Duration::from_millis(50));
let import = run_br(
&workspace,
["sync", "--import-only", "--force"],
"sync_import_resurrect",
);
assert!(import.status.success(), "import failed: {}", import.stderr);
let show = run_br(
&workspace,
["show", &id, "--json"],
"show_after_resurrect_attempt",
);
assert!(show.status.success(), "show failed: {}", show.stderr);
let payload = extract_json_payload(&show.stdout);
let show_json: Vec<Value> = serde_json::from_str(&payload).expect("show json");
assert_eq!(
show_json[0]["status"], "tombstone",
"tombstone protection should prevent resurrection"
);
}
#[test]
fn e2e_sync_content_hash_consistency() {
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create1 = run_br(
&workspace,
["create", "Issue A", "--no-auto-flush"],
"create1",
);
assert!(
create1.status.success(),
"create1 failed: {}",
create1.stderr
);
let create2 = run_br(
&workspace,
["create", "Issue B", "--no-auto-flush"],
"create2",
);
assert!(
create2.status.success(),
"create2 failed: {}",
create2.stderr
);
let flush1 = run_br(
&workspace,
["sync", "--flush-only", "--json"],
"sync_flush1",
);
assert!(
flush1.status.success(),
"sync flush1 failed: {}",
flush1.stderr
);
let payload1 = extract_json_payload(&flush1.stdout);
let flush_json1: Value = serde_json::from_str(&payload1).expect("flush json1");
let hash1 = flush_json1["content_hash"].as_str().expect("content_hash1");
let flush2 = run_br(
&workspace,
["sync", "--flush-only", "--force", "--json"],
"sync_flush2",
);
assert!(
flush2.status.success(),
"sync flush2 failed: {}",
flush2.stderr
);
let payload2 = extract_json_payload(&flush2.stdout);
let flush_json2: Value = serde_json::from_str(&payload2).expect("flush json2");
let hash2 = flush_json2["content_hash"].as_str().expect("content_hash2");
assert_eq!(
hash1, hash2,
"content hash should be consistent for same content"
);
let status = run_br(&workspace, ["sync", "--status", "--json"], "sync_status");
assert!(
status.status.success(),
"sync status failed: {}",
status.stderr
);
let status_payload = extract_json_payload(&status.stdout);
let status_json: Value = serde_json::from_str(&status_payload).expect("status json");
let stored_hash = status_json["jsonl_content_hash"]
.as_str()
.expect("stored hash");
assert_eq!(stored_hash, hash2, "stored hash should match export hash");
}
#[test]
fn e2e_jsonl_discovery_prefers_issues() {
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create = run_br(&workspace, ["create", "Discovery test"], "create");
assert!(create.status.success(), "create failed: {}", create.stderr);
let id = parse_created_id(&create.stdout);
let flush = run_br(&workspace, ["sync", "--flush-only"], "sync_flush");
assert!(
flush.status.success(),
"sync flush failed: {}",
flush.stderr
);
let issues_path = workspace.root.join(".beads").join("issues.jsonl");
assert!(issues_path.exists(), "issues.jsonl should be created");
let beads_path = workspace.root.join(".beads").join("beads.jsonl");
fs::write(&beads_path, "{\"id\": \"fake-id\", \"title\": \"Legacy issue\", \"status\": \"open\", \"issue_type\": \"task\", \"priority\": 2, \"labels\": [], \"created_at\": \"2026-01-01T00:00:00Z\", \"updated_at\": \"2026-01-01T00:00:00Z\", \"ephemeral\": false, \"pinned\": false, \"is_template\": false, \"dependencies\": [], \"comments\": []}\n").expect("write legacy");
let import = run_br(
&workspace,
["sync", "--import-only", "--force"],
"sync_import",
);
assert!(import.status.success(), "import failed: {}", import.stderr);
let show = run_br(&workspace, ["show", &id, "--json"], "show_original");
assert!(
show.status.success(),
"show original failed: {}",
show.stderr
);
let show_fake = run_br(&workspace, ["show", "fake-id", "--json"], "show_fake");
let fake_payload = extract_json_payload(&show_fake.stdout);
let fake_json: Vec<Value> = serde_json::from_str(&fake_payload).unwrap_or_default();
assert!(
fake_json.is_empty() || show_fake.stderr.contains("not found"),
"fake issue from beads.jsonl should not be imported when issues.jsonl exists"
);
}
#[test]
fn e2e_jsonl_discovery_uses_legacy_when_no_issues() {
let workspace = BrWorkspace::new();
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let issues_path = workspace.root.join(".beads").join("issues.jsonl");
if issues_path.exists() {
fs::remove_file(&issues_path).expect("remove issues.jsonl");
}
let beads_path = workspace.root.join(".beads").join("beads.jsonl");
fs::write(&beads_path, "{\"id\": \"bd-legacy1\", \"title\": \"Legacy issue\", \"status\": \"open\", \"issue_type\": \"task\", \"priority\": 2, \"labels\": [], \"created_at\": \"2026-01-01T00:00:00Z\", \"updated_at\": \"2026-01-01T00:00:00Z\", \"ephemeral\": false, \"pinned\": false, \"is_template\": false, \"dependencies\": [], \"comments\": []}\n").expect("write legacy");
let import = run_br(
&workspace,
["sync", "--import-only", "--force"],
"sync_import_legacy",
);
assert!(
import.status.success(),
"import legacy failed: {}",
import.stderr
);
let show = run_br(&workspace, ["show", "bd-legacy1", "--json"], "show_legacy");
assert!(show.status.success(), "show legacy failed: {}", show.stderr);
let payload = extract_json_payload(&show.stdout);
let show_json: Vec<Value> = serde_json::from_str(&payload).expect("show json");
assert_eq!(
show_json[0]["title"], "Legacy issue",
"legacy issue should be imported from beads.jsonl"
);
}