#![allow(
clippy::format_push_string,
clippy::uninlined_format_args,
clippy::redundant_clone,
clippy::map_unwrap_or
)]
mod common;
use common::cli::{BrWorkspace, run_br};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug)]
struct TestArtifacts {
artifact_dir: PathBuf,
test_name: String,
snapshots: Vec<(String, DirectorySnapshot)>,
jsonl_captures: Vec<(String, String)>,
command_logs: Vec<(String, String)>,
}
impl TestArtifacts {
fn new(workspace: &BrWorkspace, test_name: &str) -> Self {
let artifact_dir = workspace.log_dir.join("artifacts");
fs::create_dir_all(&artifact_dir).expect("create artifact dir");
Self {
artifact_dir,
test_name: test_name.to_string(),
snapshots: Vec::new(),
jsonl_captures: Vec::new(),
command_logs: Vec::new(),
}
}
fn capture_snapshot(&mut self, label: &str, dir: &Path) {
let snapshot = DirectorySnapshot::capture(dir);
self.snapshots.push((label.to_string(), snapshot));
}
fn capture_jsonl(&mut self, label: &str, path: &Path) {
let content = if path.exists() {
fs::read_to_string(path).unwrap_or_else(|e| format!("<error reading: {e}>"))
} else {
"<file does not exist>".to_string()
};
self.jsonl_captures.push((label.to_string(), content));
}
fn record_command(&mut self, label: &str, stdout: &str, stderr: &str, success: bool) {
let log = format!(
"=== Command: {label} ===\nSuccess: {success}\n\n--- stdout ---\n{stdout}\n\n--- stderr ---\n{stderr}\n"
);
self.command_logs.push((label.to_string(), log));
}
fn persist(&self) {
let snapshot_path = self
.artifact_dir
.join(format!("{}_snapshots.txt", self.test_name));
let mut snapshot_content = String::new();
for (label, snapshot) in &self.snapshots {
snapshot_content.push_str(&format!("\n=== Snapshot: {label} ===\n"));
snapshot_content.push_str(&format!("Files: {}\n", snapshot.files.len()));
for (path, hash) in &snapshot.files {
snapshot_content.push_str(&format!(" {path}: {hash}\n"));
}
}
fs::write(&snapshot_path, snapshot_content).expect("write snapshots");
for (label, content) in &self.jsonl_captures {
let jsonl_path = self
.artifact_dir
.join(format!("{}_{}.jsonl", self.test_name, label));
fs::write(&jsonl_path, content).expect("write jsonl capture");
}
let logs_path = self
.artifact_dir
.join(format!("{}_commands.log", self.test_name));
let logs_content: String = self
.command_logs
.iter()
.map(|(_, log)| log.as_str())
.collect();
fs::write(&logs_path, logs_content).expect("write command logs");
}
fn diff_snapshots(&self, label_before: &str, label_after: &str) -> SnapshotDiff {
let before = self
.snapshots
.iter()
.find(|(l, _)| l == label_before)
.map(|(_, s)| s);
let after = self
.snapshots
.iter()
.find(|(l, _)| l == label_after)
.map(|(_, s)| s);
match (before, after) {
(Some(b), Some(a)) => b.diff(a),
_ => SnapshotDiff::default(),
}
}
}
#[derive(Debug, Clone)]
struct DirectorySnapshot {
files: BTreeMap<String, String>,
}
impl DirectorySnapshot {
fn capture(dir: &Path) -> Self {
let mut files = BTreeMap::new();
Self::visit_dir(dir, dir, &mut files);
Self { files }
}
fn visit_dir(dir: &Path, base: &Path, files: &mut BTreeMap<String, String>) {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
let rel_path = path
.strip_prefix(base)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
if path.is_file() {
if let Ok(contents) = fs::read(&path) {
let mut digest = Sha256::new();
digest.update(&contents);
let hash = format!("{:x}", digest.finalize());
files.insert(rel_path, hash);
}
} else if path.is_dir() {
Self::visit_dir(&path, base, files);
}
}
}
}
fn diff(&self, other: &Self) -> SnapshotDiff {
let mut added = Vec::new();
let mut removed = Vec::new();
let mut modified = Vec::new();
for (path, hash) in &other.files {
match self.files.get(path) {
None => added.push(path.clone()),
Some(old_hash) if old_hash != hash => modified.push(path.clone()),
_ => {}
}
}
for path in self.files.keys() {
if !other.files.contains_key(path) {
removed.push(path.clone());
}
}
SnapshotDiff {
added,
removed,
modified,
}
}
}
#[derive(Debug, Default)]
struct SnapshotDiff {
added: Vec<String>,
removed: Vec<String>,
modified: Vec<String>,
}
impl SnapshotDiff {
#[allow(dead_code)]
fn is_empty(&self) -> bool {
self.added.is_empty() && self.removed.is_empty() && self.modified.is_empty()
}
fn only_beads_affected(&self) -> bool {
let all_changes: Vec<_> = self
.added
.iter()
.chain(self.removed.iter())
.chain(self.modified.iter())
.collect();
all_changes
.iter()
.all(|p| p.starts_with(".beads") || p.starts_with("logs"))
}
}
#[test]
#[allow(clippy::too_many_lines)]
fn e2e_sync_export_with_artifacts() {
let _log = common::test_log("e2e_sync_export_with_artifacts");
let workspace = BrWorkspace::new();
let mut artifacts = TestArtifacts::new(&workspace, "sync_export");
artifacts.capture_snapshot("initial", &workspace.root);
let init = run_br(&workspace, ["init"], "init");
artifacts.record_command("init", &init.stdout, &init.stderr, init.status.success());
assert!(init.status.success(), "init failed: {}", init.stderr);
artifacts.capture_snapshot("after_init", &workspace.root);
let create1 = run_br(
&workspace,
[
"create",
"First issue",
"-t",
"task",
"-p",
"1",
"--no-auto-flush",
],
"create1",
);
artifacts.record_command(
"create1",
&create1.stdout,
&create1.stderr,
create1.status.success(),
);
assert!(
create1.status.success(),
"create1 failed: {}",
create1.stderr
);
let create2 = run_br(
&workspace,
[
"create",
"Second issue",
"-t",
"bug",
"-p",
"2",
"--no-auto-flush",
],
"create2",
);
artifacts.record_command(
"create2",
&create2.stdout,
&create2.stderr,
create2.status.success(),
);
assert!(
create2.status.success(),
"create2 failed: {}",
create2.stderr
);
artifacts.capture_snapshot("after_creates", &workspace.root);
let sync = run_br(&workspace, ["sync", "--flush-only", "--manifest"], "export");
artifacts.record_command("export", &sync.stdout, &sync.stderr, sync.status.success());
assert!(sync.status.success(), "sync export failed: {}", sync.stderr);
let jsonl_path = workspace.root.join(".beads").join("issues.jsonl");
artifacts.capture_jsonl("after_export", &jsonl_path);
artifacts.capture_snapshot("after_export", &workspace.root);
assert!(jsonl_path.exists(), "JSONL file should exist after export");
let manifest_path = workspace.root.join(".beads").join(".manifest.json");
if !manifest_path.exists() {
eprintln!("Manifest missing! Contents of .beads:");
for entry in fs::read_dir(workspace.root.join(".beads")).unwrap() {
eprintln!(" {:?}", entry.unwrap().path());
}
}
assert!(
manifest_path.exists(),
"Manifest file should exist after export with --manifest"
);
let diff = artifacts.diff_snapshots("initial", "after_export");
assert!(
diff.only_beads_affected(),
"Export should only affect .beads/ directory (and test logs/)\n\
Added outside allowed: {:?}\n\
Modified outside allowed: {:?}",
diff.added
.iter()
.filter(|p| !p.starts_with(".beads") && !p.starts_with("logs"))
.collect::<Vec<_>>(),
diff.modified
.iter()
.filter(|p| !p.starts_with(".beads") && !p.starts_with("logs"))
.collect::<Vec<_>>()
);
artifacts.persist();
eprintln!(
"[PASS] e2e_sync_export_with_artifacts\n\
- Artifacts saved to: {:?}\n\
- JSONL size: {} bytes\n\
- Files in .beads/: {}",
artifacts.artifact_dir,
fs::metadata(&jsonl_path).map(|m| m.len()).unwrap_or(0),
artifacts
.snapshots
.last()
.map(|(_, s)| s.files.len())
.unwrap_or(0)
);
}
#[test]
fn e2e_sync_import_with_artifacts() {
let _log = common::test_log("e2e_sync_import_with_artifacts");
let workspace = BrWorkspace::new();
let mut artifacts = TestArtifacts::new(&workspace, "sync_import");
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed");
let create = run_br(
&workspace,
["create", "Original issue", "--no-auto-flush"],
"create",
);
assert!(create.status.success(), "create failed");
let flush = run_br(&workspace, ["sync", "--flush-only"], "flush");
assert!(flush.status.success(), "flush failed");
let jsonl_path = workspace.root.join(".beads").join("issues.jsonl");
artifacts.capture_jsonl("before_modification", &jsonl_path);
artifacts.capture_snapshot("before_modification", &workspace.root);
let original = fs::read_to_string(&jsonl_path).expect("read jsonl");
let modified = original.replace("Original issue", "Modified via JSONL");
fs::write(&jsonl_path, &modified).expect("write modified jsonl");
artifacts.capture_jsonl("after_modification", &jsonl_path);
artifacts.capture_snapshot("after_modification", &workspace.root);
let import = run_br(&workspace, ["sync", "--import-only", "--force"], "import");
artifacts.record_command(
"import",
&import.stdout,
&import.stderr,
import.status.success(),
);
assert!(
import.status.success(),
"sync import failed: {}",
import.stderr
);
artifacts.capture_snapshot("after_import", &workspace.root);
let list = run_br(&workspace, ["list", "--json"], "list_verify");
assert!(list.status.success(), "list failed");
assert!(
list.stdout.contains("Modified via JSONL"),
"Import should have updated the issue title\n\
stdout: {}",
list.stdout
);
let diff = artifacts.diff_snapshots("before_modification", "after_import");
assert!(
diff.only_beads_affected(),
"Import should only affect .beads/ directory (and test logs/)\n\
Added outside allowed: {:?}\n\
Modified outside allowed: {:?}",
diff.added
.iter()
.filter(|p| !p.starts_with(".beads") && !p.starts_with("logs"))
.collect::<Vec<_>>(),
diff.modified
.iter()
.filter(|p| !p.starts_with(".beads") && !p.starts_with("logs"))
.collect::<Vec<_>>()
);
artifacts.persist();
eprintln!(
"[PASS] e2e_sync_import_with_artifacts\n\
- Artifacts saved to: {:?}",
artifacts.artifact_dir
);
}
#[test]
fn e2e_sync_full_cycle_with_artifacts() {
let _log = common::test_log("e2e_sync_full_cycle_with_artifacts");
let workspace = BrWorkspace::new();
let mut artifacts = TestArtifacts::new(&workspace, "sync_full_cycle");
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed");
artifacts.capture_snapshot("after_init", &workspace.root);
for (i, (title, typ)) in [
("Bug: Login fails", "bug"),
("Feature: Dark mode", "feature"),
("Task: Update docs", "task"),
]
.iter()
.enumerate()
{
let create = run_br(
&workspace,
["create", title, "-t", typ, "--no-auto-flush"],
&format!("create{i}"),
);
artifacts.record_command(
&format!("create{i}"),
&create.stdout,
&create.stderr,
create.status.success(),
);
assert!(create.status.success(), "create{i} failed");
}
artifacts.capture_snapshot("after_creates", &workspace.root);
let export1 = run_br(&workspace, ["sync", "--flush-only"], "export1");
artifacts.record_command(
"export1",
&export1.stdout,
&export1.stderr,
export1.status.success(),
);
assert!(export1.status.success(), "export1 failed");
let jsonl_path = workspace.root.join(".beads").join("issues.jsonl");
artifacts.capture_jsonl("phase1_export", &jsonl_path);
artifacts.capture_snapshot("after_export1", &workspace.root);
let original = fs::read_to_string(&jsonl_path).expect("read jsonl");
let modified = original.replace("Bug: Login fails", "Bug: Login fails (critical)");
fs::write(&jsonl_path, &modified).expect("write modified");
artifacts.capture_jsonl("phase2_modified", &jsonl_path);
let import = run_br(&workspace, ["sync", "--import-only", "--force"], "import");
artifacts.record_command(
"import",
&import.stdout,
&import.stderr,
import.status.success(),
);
assert!(import.status.success(), "import failed");
artifacts.capture_snapshot("after_import", &workspace.root);
let export2 = run_br(&workspace, ["sync", "--flush-only", "--force"], "export2");
artifacts.record_command(
"export2",
&export2.stdout,
&export2.stderr,
export2.status.success(),
);
assert!(export2.status.success(), "export2 failed");
artifacts.capture_jsonl("phase4_reexport", &jsonl_path);
artifacts.capture_snapshot("after_export2", &workspace.root);
let list = run_br(&workspace, ["list", "--json"], "list_verify");
assert!(
list.stdout.contains("critical"),
"Modification should persist through full cycle"
);
let status = run_br(&workspace, ["sync", "--status"], "status");
artifacts.record_command(
"status",
&status.stdout,
&status.stderr,
status.status.success(),
);
assert!(status.status.success(), "status check failed");
artifacts.persist();
eprintln!(
"[PASS] e2e_sync_full_cycle_with_artifacts\n\
- Phases completed: init -> create x3 -> export -> modify -> import -> export\n\
- Artifacts saved to: {:?}",
artifacts.artifact_dir
);
}
#[test]
fn e2e_sync_status_with_artifacts() {
let _log = common::test_log("e2e_sync_status_with_artifacts");
let workspace = BrWorkspace::new();
let mut artifacts = TestArtifacts::new(&workspace, "sync_status");
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed");
let status1 = run_br(&workspace, ["sync", "--status", "--json"], "status_empty");
artifacts.record_command(
"status_empty",
&status1.stdout,
&status1.stderr,
status1.status.success(),
);
assert!(status1.status.success(), "status check failed");
let create = run_br(&workspace, ["create", "Test issue"], "create");
assert!(create.status.success(), "create failed");
let status2 = run_br(&workspace, ["sync", "--status", "--json"], "status_dirty");
artifacts.record_command(
"status_dirty",
&status2.stdout,
&status2.stderr,
status2.status.success(),
);
assert!(status2.status.success(), "status check failed");
let export = run_br(&workspace, ["sync", "--flush-only"], "export");
assert!(export.status.success(), "export failed");
let status3 = run_br(&workspace, ["sync", "--status", "--json"], "status_clean");
artifacts.record_command(
"status_clean",
&status3.stdout,
&status3.stderr,
status3.status.success(),
);
assert!(status3.status.success(), "status check failed");
artifacts.persist();
eprintln!(
"[PASS] e2e_sync_status_with_artifacts\n\
- Status checks: empty -> dirty -> clean\n\
- Artifacts saved to: {:?}",
artifacts.artifact_dir
);
}
#[test]
fn e2e_sync_error_conflict_markers() {
let _log = common::test_log("e2e_sync_error_conflict_markers");
let workspace = BrWorkspace::new();
let mut artifacts = TestArtifacts::new(&workspace, "sync_error_conflict");
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed");
let create = run_br(&workspace, ["create", "Test issue"], "create");
assert!(create.status.success(), "create failed");
let export = run_br(&workspace, ["sync", "--flush-only"], "export");
assert!(export.status.success(), "export failed");
let jsonl_path = workspace.root.join(".beads").join("issues.jsonl");
let original = fs::read_to_string(&jsonl_path).expect("read jsonl");
let corrupted = format!("<<<<<<< HEAD\n{original}=======\n{original}>>>>>>> branch\n");
fs::write(&jsonl_path, &corrupted).expect("write corrupted");
artifacts.capture_jsonl("corrupted", &jsonl_path);
let import = run_br(
&workspace,
["sync", "--import-only", "--force"],
"import_fail",
);
artifacts.record_command(
"import_fail",
&import.stdout,
&import.stderr,
import.status.success(),
);
assert!(
!import.status.success(),
"Import should fail with conflict markers"
);
assert!(
import.stderr.to_lowercase().contains("conflict")
|| import.stderr.to_lowercase().contains("marker"),
"Error should mention conflict markers\nstderr: {}",
import.stderr
);
artifacts.persist();
eprintln!(
"[PASS] e2e_sync_error_conflict_markers\n\
- Correctly rejected JSONL with conflict markers\n\
- Artifacts saved to: {:?}",
artifacts.artifact_dir
);
}
#[test]
fn e2e_sync_export_empty_db() {
let _log = common::test_log("e2e_sync_export_empty_db");
let workspace = BrWorkspace::new();
let mut artifacts = TestArtifacts::new(&workspace, "sync_export_empty");
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed");
artifacts.capture_snapshot("after_init", &workspace.root);
let export1 = run_br(&workspace, ["sync", "--flush-only"], "export_no_force");
artifacts.record_command(
"export_no_force",
&export1.stdout,
&export1.stderr,
export1.status.success(),
);
let export2 = run_br(
&workspace,
["sync", "--flush-only", "--force"],
"export_force",
);
artifacts.record_command(
"export_force",
&export2.stdout,
&export2.stderr,
export2.status.success(),
);
assert!(
export2.status.success(),
"export --force failed: {}",
export2.stderr
);
artifacts.capture_snapshot("after_export", &workspace.root);
let jsonl_path = workspace.root.join(".beads").join("issues.jsonl");
artifacts.capture_jsonl("empty_export", &jsonl_path);
artifacts.persist();
eprintln!(
"[PASS] e2e_sync_export_empty_db\n\
- Empty DB export handled correctly\n\
- Artifacts saved to: {:?}",
artifacts.artifact_dir
);
}
#[test]
fn e2e_sync_deterministic_export() {
let _log = common::test_log("e2e_sync_deterministic_export");
let workspace = BrWorkspace::new();
let mut artifacts = TestArtifacts::new(&workspace, "sync_deterministic");
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed");
for title in ["Zebra", "Apple", "Mango", "Banana"] {
let create = run_br(&workspace, ["create", title], &format!("create_{title}"));
assert!(create.status.success(), "create failed");
}
let jsonl_path = workspace.root.join(".beads").join("issues.jsonl");
let mut exports = Vec::new();
for i in 0..3 {
let export = run_br(
&workspace,
["sync", "--flush-only", "--force"],
&format!("export{i}"),
);
assert!(export.status.success(), "export{i} failed");
let content = fs::read_to_string(&jsonl_path).expect("read jsonl");
artifacts.capture_jsonl(&format!("export{i}"), &jsonl_path);
exports.push(content);
}
assert!(
exports.windows(2).all(|w| w[0] == w[1]),
"Multiple exports should produce identical JSONL"
);
let lines: Vec<&str> = exports[0].lines().collect();
let mut ids: Vec<String> = Vec::new();
for line in lines {
if let Ok(json) = serde_json::from_str::<serde_json::Value>(line)
&& let Some(id) = json.get("id").and_then(|v| v.as_str())
{
ids.push(id.to_string());
}
}
let mut sorted_ids = ids.clone();
sorted_ids.sort();
assert_eq!(ids, sorted_ids, "JSONL should be sorted by ID");
artifacts.persist();
eprintln!(
"[PASS] e2e_sync_deterministic_export\n\
- 3 exports produced identical output\n\
- Issues sorted by ID: {:?}\n\
- Artifacts saved to: {:?}",
ids, artifacts.artifact_dir
);
}
#[test]
fn e2e_staleness_hash_check_prevents_false_touch() {
use std::thread;
use std::time::Duration;
let _log = common::test_log("e2e_staleness_hash_check_prevents_false_touch");
let workspace = BrWorkspace::new();
let mut artifacts = TestArtifacts::new(&workspace, "staleness_hash_check");
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create = run_br(&workspace, ["create", "Test staleness"], "create");
assert!(create.status.success(), "create failed: {}", create.stderr);
let export = run_br(&workspace, ["sync", "--flush-only"], "export");
assert!(export.status.success(), "export failed: {}", export.stderr);
let status1 = run_br(
&workspace,
["sync", "--status", "--json"],
"status_after_export",
);
artifacts.record_command(
"status_after_export",
&status1.stdout,
&status1.stderr,
status1.status.success(),
);
assert!(status1.status.success(), "status check failed");
let payload1 = common::cli::extract_json_payload(&status1.stdout);
let json1: serde_json::Value = serde_json::from_str(&payload1).unwrap_or_else(|e| {
panic!(
"parse status json failed: {}\nSTDOUT:\n{}\nSTDERR:\n{}",
e, status1.stdout, status1.stderr
);
});
assert!(
!json1["jsonl_newer"].as_bool().unwrap_or(true),
"JSONL should not be marked newer after export"
);
thread::sleep(Duration::from_millis(100));
let jsonl_path = workspace.root.join(".beads").join("issues.jsonl");
let content = fs::read_to_string(&jsonl_path).expect("read jsonl");
fs::write(&jsonl_path, &content).expect("touch jsonl");
artifacts.capture_jsonl("after_touch", &jsonl_path);
let status2 = run_br(
&workspace,
["sync", "--status", "--json"],
"status_after_touch",
);
artifacts.record_command(
"status_after_touch",
&status2.stdout,
&status2.stderr,
status2.status.success(),
);
assert!(status2.status.success(), "status check failed");
let payload2 = common::cli::extract_json_payload(&status2.stdout);
let json2: serde_json::Value = serde_json::from_str(&payload2).expect("parse status json");
assert!(
!json2["jsonl_newer"].as_bool().unwrap_or(true),
"JSONL should NOT be marked newer after touch (hash unchanged)\n\
mtime updated but content hash is the same\n\
status output: {}",
status2.stdout
);
artifacts.persist();
eprintln!(
"[PASS] e2e_staleness_hash_check_prevents_false_touch\n\
- Exported JSONL\n\
- Touched file (mtime changed, content unchanged)\n\
- Hash check correctly prevented false staleness\n\
- Artifacts saved to: {:?}",
artifacts.artifact_dir
);
}
#[test]
fn e2e_staleness_detects_real_content_change() {
let _log = common::test_log("e2e_staleness_detects_real_content_change");
let workspace = BrWorkspace::new();
let mut artifacts = TestArtifacts::new(&workspace, "staleness_real_change");
let init = run_br(&workspace, ["init"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let create = run_br(&workspace, ["create", "Test staleness"], "create");
assert!(create.status.success(), "create failed: {}", create.stderr);
let export = run_br(&workspace, ["sync", "--flush-only"], "export");
assert!(export.status.success(), "export failed: {}", export.stderr);
let jsonl_path = workspace.root.join(".beads").join("issues.jsonl");
let mut content = fs::read_to_string(&jsonl_path).expect("read jsonl");
artifacts.capture_jsonl("before_modify", &jsonl_path);
content.push_str("# External comment added\n");
fs::write(&jsonl_path, &content).expect("write modified jsonl");
artifacts.capture_jsonl("after_modify", &jsonl_path);
let status = run_br(
&workspace,
["sync", "--status", "--json"],
"status_after_modify",
);
artifacts.record_command(
"status_after_modify",
&status.stdout,
&status.stderr,
status.status.success(),
);
assert!(status.status.success(), "status check failed");
let payload = common::cli::extract_json_payload(&status.stdout);
let json: serde_json::Value = serde_json::from_str(&payload).unwrap_or_else(|e| {
panic!(
"parse status json failed: {}\nSTDOUT:\n{}\nSTDERR:\n{}",
e, status.stdout, status.stderr
);
});
assert!(
json["jsonl_newer"].as_bool().unwrap_or(false),
"JSONL should be marked newer after real content change\n\
Content was modified, hash should differ\n\
status output: {}",
status.stdout
);
artifacts.persist();
eprintln!(
"[PASS] e2e_staleness_detects_real_content_change\n\
- Exported JSONL\n\
- Modified file content\n\
- Staleness correctly detected (hash changed)\n\
- Artifacts saved to: {:?}",
artifacts.artifact_dir
);
}
#[test]
#[allow(clippy::too_many_lines)]
fn e2e_sync_import_force_preserves_integrity_and_close_works() {
const ISSUE_COUNT: usize = 220;
let _log = common::test_log("e2e_sync_import_force_preserves_integrity_and_close_works");
let workspace = BrWorkspace::new();
let mut artifacts = TestArtifacts::new(&workspace, "sync_import_force_preserves_integrity");
let init = run_br(&workspace, ["init", "--prefix", "rr"], "init");
assert!(init.status.success(), "init failed: {}", init.stderr);
let mut last_id: String = String::new();
for i in 0..ISSUE_COUNT {
let title = format!("issue {i}");
let create = run_br(
&workspace,
["create", &title, "-p", "3", "--silent", "--no-auto-flush"],
"create_bulk",
);
assert!(
create.status.success(),
"create {i} failed: {}",
create.stderr
);
last_id = create.stdout.trim().to_string();
}
assert!(!last_id.is_empty(), "expected at least one created id");
let flush = run_br(&workspace, ["sync", "--flush-only"], "flush");
assert!(flush.status.success(), "flush failed: {}", flush.stderr);
let beads_dir = workspace.root.join(".beads");
let db_path = beads_dir.join("beads.db");
let jsonl_path = beads_dir.join("issues.jsonl");
artifacts.capture_jsonl("after_flush", &jsonl_path);
for sidecar in [
"beads.db",
"beads.db-wal",
"beads.db-shm",
"beads.db-journal",
] {
let path = beads_dir.join(sidecar);
if path.exists() {
fs::remove_file(&path).expect("remove db sidecar");
}
}
let reinit = run_br(&workspace, ["init", "--prefix", "rr", "--force"], "reinit");
assert!(reinit.status.success(), "reinit failed: {}", reinit.stderr);
let import = run_br(
&workspace,
["sync", "--import-only", "--force"],
"force_import",
);
artifacts.record_command(
"force_import",
&import.stdout,
&import.stderr,
import.status.success(),
);
assert!(
import.status.success(),
"force import failed: {}",
import.stderr
);
let integrity_before_close = run_sqlite3_pragma_integrity_check(&db_path);
artifacts.record_command(
"integrity_check_before_close",
&integrity_before_close,
"",
integrity_before_close.trim() == "ok",
);
assert_eq!(
integrity_before_close.trim(),
"ok",
"C sqlite3 integrity_check must pass after force/rebuild import (issue #248).\n\
Without the post-import VACUUM+REINDEX, this returns\n\
'database disk image is malformed (11)'.\n\
output: {integrity_before_close}"
);
let show = run_br(&workspace, ["show", &last_id], "show_before_close");
assert!(
show.status.success(),
"show {last_id} failed: {}",
show.stderr
);
let close = run_br(&workspace, ["close", &last_id], "close");
artifacts.record_command(
"close",
&close.stdout,
&close.stderr,
close.status.success(),
);
assert!(
close.status.success(),
"close {last_id} failed after force/rebuild import: stdout={} stderr={}",
close.stdout,
close.stderr
);
assert!(
!close.stderr.contains("Issue not found"),
"close should not report 'Issue not found' for an id visible to show (issue #248):\n{}",
close.stderr
);
let integrity_after_close = run_sqlite3_pragma_integrity_check(&db_path);
artifacts.record_command(
"integrity_check_after_close",
&integrity_after_close,
"",
integrity_after_close.trim() == "ok",
);
assert_eq!(
integrity_after_close.trim(),
"ok",
"C sqlite3 integrity_check must still pass after a successful close (issue #248).\n\
output: {integrity_after_close}"
);
artifacts.persist();
eprintln!(
"[PASS] e2e_sync_import_force_preserves_integrity_and_close_works\n\
- Exported {ISSUE_COUNT} issues to JSONL\n\
- Removed DB and force-imported from JSONL\n\
- C sqlite3 integrity_check clean before and after close\n\
- Close succeeded without 'Issue not found'\n\
- Artifacts saved to: {:?}",
artifacts.artifact_dir
);
}
fn run_sqlite3_pragma_integrity_check(db_path: &Path) -> String {
match std::process::Command::new("sqlite3")
.arg(db_path)
.arg("PRAGMA integrity_check;")
.output()
{
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
if stdout.trim().is_empty() {
format!(
"<sqlite3 exited {} with empty stdout; stderr: {}>",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
)
} else {
stdout
}
}
Err(err) => format!("sqlite3-missing: {err}"),
}
}