#![allow(clippy::all, clippy::pedantic, clippy::nursery, dead_code)]
mod common;
use common::cli::extract_json_payload;
use serde::Serialize;
use serde_json::Value;
use std::collections::HashSet;
use std::fs;
use std::path::Path;
use std::process::Command;
use tempfile::TempDir;
use tracing::info;
fn get_bd_binary() -> String {
std::env::var("BD_BINARY").unwrap_or_else(|_| "bd".to_string())
}
fn bd_available() -> bool {
let bd_bin = get_bd_binary();
Command::new(&bd_bin)
.arg("version")
.output()
.is_ok_and(|o| {
if !o.status.success() {
return false;
}
let stdout = String::from_utf8_lossy(&o.stdout);
let Some(first_token) = stdout.split_whitespace().next() else {
return false;
};
match first_token.to_ascii_lowercase().as_str() {
"bd" | "beads" => true,
"br" => false,
_ => false,
}
})
}
macro_rules! skip_if_no_bd {
() => {
if !bd_available() {
eprintln!(
"Skipping test: 'bd' binary missing or aliased to br. \
Set BD_BINARY to a Go bd path for conformance runs."
);
return;
}
};
}
const TIMESTAMP_FIELDS: &[&str] = &[
"created_at",
"updated_at",
"closed_at",
"defer_until",
"due_at",
"deleted_at",
"compacted_at",
];
const STRUCTURAL_FIELDS: &[&str] = &[
"title",
"status",
"priority",
"type",
"assignee",
"labels",
"depends_on",
"blocks",
"external_ref",
"description",
];
const IGNORABLE_BR_ONLY_FIELDS: &[&str] = &["compaction_level", "original_size", "source_repo"];
const IMPLEMENTATION_SPECIFIC_FIELDS: &[&str] = &["deleted_by", "delete_reason"];
const EQUIVALENT_CLOSE_REASONS: &[(&str, &str)] = &[("done", "Closed")];
#[derive(Debug, Default)]
pub struct DiffResult {
pub matched: bool,
pub structural_diffs: Vec<FieldDiff>,
pub timestamp_drifts: Vec<String>,
pub extra_br_fields: Vec<String>,
pub extra_bd_fields: Vec<String>,
pub normalized_log: Vec<String>,
}
#[derive(Debug)]
pub struct FieldDiff {
pub path: String,
pub br_value: String,
pub bd_value: String,
pub explanation: String,
}
impl DiffResult {
pub fn explain(&self) -> String {
let mut parts = Vec::new();
if !self.structural_diffs.is_empty() {
parts.push("Structural differences:".to_string());
for diff in &self.structural_diffs {
parts.push(format!(
" - {}: br='{}' vs bd='{}' ({})",
diff.path, diff.br_value, diff.bd_value, diff.explanation
));
}
}
if !self.timestamp_drifts.is_empty() {
parts.push(format!(
"Timestamp drifts (within tolerance): {}",
self.timestamp_drifts.join(", ")
));
}
if !self.extra_br_fields.is_empty() {
parts.push(format!(
"Fields only in br: {}",
self.extra_br_fields.join(", ")
));
}
if !self.extra_bd_fields.is_empty() {
parts.push(format!(
"Fields only in bd: {}",
self.extra_bd_fields.join(", ")
));
}
if parts.is_empty() {
"No differences found".to_string()
} else {
parts.join("\n")
}
}
}
fn normalize_json(value: &mut Value, path: &str, log: &mut Vec<String>) {
match value {
Value::Object(map) => {
for (key, val) in map.iter_mut() {
let field_path = if path.is_empty() {
key.clone()
} else {
format!("{}.{}", path, key)
};
if TIMESTAMP_FIELDS.contains(&key.as_str()) {
if let Some(s) = val.as_str() {
if !s.is_empty() {
log.push(format!("Masked timestamp: {}", field_path));
*val = Value::String("NORMALIZED_TIMESTAMP".to_string());
}
}
}
else if key == "id" || key.ends_with("_id") {
if let Some(s) = val.as_str() {
if let Some(dash_pos) = s.rfind('-') {
let prefix = &s[..dash_pos];
log.push(format!("Normalized ID: {} ({})", field_path, s));
*val = Value::String(format!("{}-HASH", prefix));
}
}
} else {
normalize_json(val, &field_path, log);
}
}
}
Value::Array(arr) => {
for (i, item) in arr.iter_mut().enumerate() {
normalize_json(item, &format!("{}[{}]", path, i), log);
}
arr.sort_by(|a, b| {
serde_json::to_string(a)
.unwrap_or_default()
.cmp(&serde_json::to_string(b).unwrap_or_default())
});
}
_ => {}
}
}
fn compare_json_with_diff(br: &Value, bd: &Value, path: &str, result: &mut DiffResult) {
match (br, bd) {
(Value::Object(br_map), Value::Object(bd_map)) => {
let all_keys: HashSet<_> = br_map.keys().chain(bd_map.keys()).collect();
for key in all_keys {
let field_path = if path.is_empty() {
key.clone()
} else {
format!("{}.{}", path, key)
};
match (br_map.get(key), bd_map.get(key)) {
(Some(br_val), Some(bd_val)) => {
compare_json_with_diff(br_val, bd_val, &field_path, result);
}
(Some(_), None) => {
if !IGNORABLE_BR_ONLY_FIELDS.contains(&key.as_str()) {
result.extra_br_fields.push(field_path);
}
}
(None, Some(_)) => {
result.extra_bd_fields.push(field_path);
}
(None, None) => {}
}
}
}
(Value::Array(br_arr), Value::Array(bd_arr)) => {
if br_arr.len() != bd_arr.len() {
result.structural_diffs.push(FieldDiff {
path: path.to_string(),
br_value: format!("array[{}]", br_arr.len()),
bd_value: format!("array[{}]", bd_arr.len()),
explanation: "Array length mismatch".to_string(),
});
}
for (i, (br_item, bd_item)) in br_arr.iter().zip(bd_arr.iter()).enumerate() {
compare_json_with_diff(br_item, bd_item, &format!("{}[{}]", path, i), result);
}
}
_ => {
if br != bd {
let is_equivalent_close_reason = path.ends_with("close_reason")
&& br
.as_str()
.zip(bd.as_str())
.is_some_and(|(br_str, bd_str)| {
EQUIVALENT_CLOSE_REASONS.iter().any(|(br_eq, bd_eq)| {
(br_str == *br_eq && bd_str == *bd_eq)
|| (br_str == *bd_eq && bd_str == *br_eq)
})
});
let is_implementation_specific = IMPLEMENTATION_SPECIFIC_FIELDS
.iter()
.any(|f| path.ends_with(f));
if is_equivalent_close_reason || is_implementation_specific {
} else {
let is_structural = STRUCTURAL_FIELDS.iter().any(|f| path.ends_with(f));
if is_structural || !path.contains("NORMALIZED") {
result.structural_diffs.push(FieldDiff {
path: path.to_string(),
br_value: format!("{:?}", br),
bd_value: format!("{:?}", bd),
explanation: if is_structural {
"Structural field mismatch".to_string()
} else {
"Value mismatch".to_string()
},
});
}
}
}
}
}
}
fn compare_jsonl_files(br_path: &Path, bd_path: &Path) -> DiffResult {
let mut result = DiffResult::default();
let br_content = fs::read_to_string(br_path).unwrap_or_default();
let bd_content = fs::read_to_string(bd_path).unwrap_or_default();
let br_entries: Vec<Value> = br_content
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str(l).ok())
.collect();
let bd_entries: Vec<Value> = bd_content
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str(l).ok())
.collect();
if br_entries.len() != bd_entries.len() {
result.structural_diffs.push(FieldDiff {
path: "jsonl_line_count".to_string(),
br_value: format!("{}", br_entries.len()),
bd_value: format!("{}", bd_entries.len()),
explanation: "JSONL line count mismatch".to_string(),
});
}
let mut br_normalized: Vec<Value> = br_entries.clone();
let mut bd_normalized: Vec<Value> = bd_entries.clone();
for entry in &mut br_normalized {
normalize_json(entry, "", &mut result.normalized_log);
}
for entry in &mut bd_normalized {
normalize_json(entry, "", &mut result.normalized_log);
}
br_normalized.sort_by(|a, b| {
let a_title = a.get("title").and_then(|v| v.as_str()).unwrap_or("");
let b_title = b.get("title").and_then(|v| v.as_str()).unwrap_or("");
a_title.cmp(b_title)
});
bd_normalized.sort_by(|a, b| {
let a_title = a.get("title").and_then(|v| v.as_str()).unwrap_or("");
let b_title = b.get("title").and_then(|v| v.as_str()).unwrap_or("");
a_title.cmp(b_title)
});
for (i, (br_entry, bd_entry)) in br_normalized.iter().zip(bd_normalized.iter()).enumerate() {
compare_json_with_diff(br_entry, bd_entry, &format!("entry[{}]", i), &mut result);
}
result.matched = result.structural_diffs.is_empty()
&& result.extra_br_fields.is_empty()
&& result.extra_bd_fields.is_empty();
result
}
pub struct WorkflowWorkspace {
pub temp_dir: TempDir,
pub br_root: std::path::PathBuf,
pub bd_root: std::path::PathBuf,
pub log_dir: std::path::PathBuf,
pub workflow_log: Vec<WorkflowStep>,
}
#[derive(Debug, Clone, Serialize)]
pub struct WorkflowStep {
pub step_num: usize,
pub command: String,
pub br_success: bool,
pub bd_success: bool,
pub br_stdout_len: usize,
pub bd_stdout_len: usize,
}
impl WorkflowWorkspace {
pub fn new(_name: &str) -> Self {
let temp_dir = TempDir::new().expect("create temp dir");
let root = temp_dir.path().to_path_buf();
let br_root = root.join("br_workspace");
let bd_root = root.join("bd_workspace");
let log_dir = root.join("logs");
fs::create_dir_all(&br_root).expect("create br workspace");
fs::create_dir_all(&bd_root).expect("create bd workspace");
fs::create_dir_all(&log_dir).expect("create log dir");
std::process::Command::new("git")
.args(["init"])
.current_dir(&br_root)
.output()
.ok();
std::process::Command::new("git")
.args(["init"])
.current_dir(&bd_root)
.output()
.ok();
Self {
temp_dir,
br_root,
bd_root,
log_dir,
workflow_log: Vec::new(),
}
}
pub fn init_both(&mut self) {
self.run_step(0, &["init", "--prefix", "bd"]);
}
pub fn run_step(&mut self, step_num: usize, args: &[&str]) -> (CmdOutput, CmdOutput) {
let br_out = self.run_br(args);
let bd_out = self.run_bd(args);
self.workflow_log.push(WorkflowStep {
step_num,
command: args.join(" "),
br_success: br_out.status.success(),
bd_success: bd_out.status.success(),
br_stdout_len: br_out.stdout.len(),
bd_stdout_len: bd_out.stdout.len(),
});
(br_out, bd_out)
}
pub fn run_br(&self, args: &[&str]) -> CmdOutput {
let mut cmd = std::process::Command::new(assert_cmd::cargo::cargo_bin!("br"));
cmd.current_dir(&self.br_root);
cmd.args(args);
cmd.env("NO_COLOR", "1");
cmd.env("HOME", &self.br_root);
let start = std::time::Instant::now();
let output = cmd.output().expect("run br");
let duration = start.elapsed();
CmdOutput {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
status: output.status,
duration,
}
}
pub fn run_bd(&self, args: &[&str]) -> CmdOutput {
let mut cmd = std::process::Command::new(get_bd_binary());
cmd.current_dir(&self.bd_root);
cmd.args(args);
cmd.env("NO_COLOR", "1");
cmd.env("HOME", &self.bd_root);
let start = std::time::Instant::now();
let output = cmd.output().expect("run bd");
let duration = start.elapsed();
CmdOutput {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
status: output.status,
duration,
}
}
pub fn jsonl_paths(&self) -> (std::path::PathBuf, std::path::PathBuf) {
(
self.br_root.join(".beads").join("issues.jsonl"),
self.bd_root.join(".beads").join("issues.jsonl"),
)
}
pub fn compare_jsonl(&self) -> DiffResult {
let (br_jsonl, bd_jsonl) = self.jsonl_paths();
compare_jsonl_files(&br_jsonl, &bd_jsonl)
}
pub fn flush_both(&mut self, step_num: usize) -> (CmdOutput, CmdOutput) {
self.run_step(step_num, &["sync", "--flush-only"])
}
pub fn write_log(&self) {
let log_path = self.log_dir.join("workflow.json");
let json = serde_json::to_string_pretty(&self.workflow_log).unwrap_or_default();
fs::write(&log_path, json).ok();
}
pub fn extract_id(output: &str) -> Option<String> {
let json_str = extract_json_payload(output);
if let Ok(val) = serde_json::from_str::<Value>(&json_str) {
if let Some(id) = val.get("id").and_then(|v| v.as_str()) {
return Some(id.to_string());
}
if let Some(arr) = val.as_array() {
if let Some(first) = arr.first() {
if let Some(id) = first.get("id").and_then(|v| v.as_str()) {
return Some(id.to_string());
}
}
}
}
None
}
}
#[derive(Debug)]
pub struct CmdOutput {
pub stdout: String,
pub stderr: String,
pub status: std::process::ExitStatus,
pub duration: std::time::Duration,
}
#[test]
fn conformance_workflow_create_update_lifecycle() {
skip_if_no_bd!();
common::init_test_logging();
info!("Starting conformance_workflow_create_update_lifecycle");
let mut ws = WorkflowWorkspace::new("create_update_lifecycle");
ws.init_both();
let (br_c1, bd_c1) = ws.run_step(
1,
&[
"create",
"Feature A",
"--type",
"feature",
"--priority",
"1",
"--json",
],
);
assert!(br_c1.status.success(), "br create failed: {}", br_c1.stderr);
assert!(bd_c1.status.success(), "bd create failed: {}", bd_c1.stderr);
let (br_c2, bd_c2) = ws.run_step(
2,
&[
"create",
"Bug B",
"--type",
"bug",
"--priority",
"0",
"--json",
],
);
assert!(br_c2.status.success());
assert!(bd_c2.status.success());
let (br_c3, bd_c3) = ws.run_step(
3,
&[
"create",
"Task C",
"--type",
"task",
"--priority",
"2",
"--json",
],
);
assert!(br_c3.status.success());
assert!(bd_c3.status.success());
let br_id1 = WorkflowWorkspace::extract_id(&br_c1.stdout).expect("br id1");
let bd_id1 = WorkflowWorkspace::extract_id(&bd_c1.stdout).expect("bd id1");
let br_id2 = WorkflowWorkspace::extract_id(&br_c2.stdout).expect("br id2");
let bd_id2 = WorkflowWorkspace::extract_id(&bd_c2.stdout).expect("bd id2");
let (br_u1, bd_u1) = (
ws.run_br(&["update", &br_id1, "--status", "in_progress", "--json"]),
ws.run_bd(&["update", &bd_id1, "--status", "in_progress", "--json"]),
);
assert!(br_u1.status.success(), "br update failed: {}", br_u1.stderr);
assert!(bd_u1.status.success(), "bd update failed: {}", bd_u1.stderr);
let (br_u2, bd_u2) = (
ws.run_br(&["update", &br_id2, "--priority", "1", "--json"]),
ws.run_bd(&["update", &bd_id2, "--priority", "1", "--json"]),
);
assert!(br_u2.status.success());
assert!(bd_u2.status.success());
let (br_list, bd_list) = ws.run_step(6, &["list", "--json"]);
assert!(br_list.status.success());
assert!(bd_list.status.success());
let br_json = extract_json_payload(&br_list.stdout);
let bd_json = extract_json_payload(&bd_list.stdout);
let mut br_val: Value = serde_json::from_str(&br_json).expect("parse br");
let mut bd_val: Value = serde_json::from_str(&bd_json).expect("parse bd");
let mut log = Vec::new();
normalize_json(&mut br_val, "", &mut log);
normalize_json(&mut bd_val, "", &mut log);
let br_count = br_val.as_array().map(|a| a.len()).unwrap_or(0);
let bd_count = bd_val.as_array().map(|a| a.len()).unwrap_or(0);
assert_eq!(
br_count, bd_count,
"Issue counts differ: br={}, bd={}",
br_count, bd_count
);
assert_eq!(br_count, 3, "Expected 3 issues");
ws.flush_both(7);
let diff = ws.compare_jsonl();
ws.write_log();
assert!(diff.matched, "JSONL comparison failed:\n{}", diff.explain());
info!("conformance_workflow_create_update_lifecycle passed");
}
#[test]
fn conformance_workflow_dependency_chain() {
skip_if_no_bd!();
common::init_test_logging();
info!("Starting conformance_workflow_dependency_chain");
let mut ws = WorkflowWorkspace::new("dependency_chain");
ws.init_both();
let (br_ca, bd_ca) = ws.run_step(1, &["create", "Foundation A", "--json"]);
assert!(br_ca.status.success());
assert!(bd_ca.status.success());
let (br_cb, bd_cb) = ws.run_step(2, &["create", "Build on A", "--json"]);
assert!(br_cb.status.success());
assert!(bd_cb.status.success());
let (br_cc, bd_cc) = ws.run_step(3, &["create", "Final C", "--json"]);
assert!(br_cc.status.success());
assert!(bd_cc.status.success());
let br_id_a = WorkflowWorkspace::extract_id(&br_ca.stdout).expect("br A");
let bd_id_a = WorkflowWorkspace::extract_id(&bd_ca.stdout).expect("bd A");
let br_id_b = WorkflowWorkspace::extract_id(&br_cb.stdout).expect("br B");
let bd_id_b = WorkflowWorkspace::extract_id(&bd_cb.stdout).expect("bd B");
let br_id_c = WorkflowWorkspace::extract_id(&br_cc.stdout).expect("br C");
let bd_id_c = WorkflowWorkspace::extract_id(&bd_cc.stdout).expect("bd C");
let br_dep1 = ws.run_br(&["dep", "add", &br_id_b, &br_id_a]);
let bd_dep1 = ws.run_bd(&["dep", "add", &bd_id_b, &bd_id_a]);
assert!(
br_dep1.status.success(),
"br dep add failed: {}",
br_dep1.stderr
);
assert!(
bd_dep1.status.success(),
"bd dep add failed: {}",
bd_dep1.stderr
);
let br_dep2 = ws.run_br(&["dep", "add", &br_id_c, &br_id_b]);
let bd_dep2 = ws.run_bd(&["dep", "add", &bd_id_c, &bd_id_b]);
assert!(br_dep2.status.success());
assert!(bd_dep2.status.success());
let (br_blocked, bd_blocked) = ws.run_step(6, &["blocked", "--json"]);
assert!(br_blocked.status.success());
assert!(bd_blocked.status.success());
let br_blocked_json = extract_json_payload(&br_blocked.stdout);
let bd_blocked_json = extract_json_payload(&bd_blocked.stdout);
let br_blocked_val: Value =
serde_json::from_str(&br_blocked_json).unwrap_or(Value::Array(vec![]));
let bd_blocked_val: Value =
serde_json::from_str(&bd_blocked_json).unwrap_or(Value::Array(vec![]));
let br_blocked_count = br_blocked_val.as_array().map(|a| a.len()).unwrap_or(0);
let bd_blocked_count = bd_blocked_val.as_array().map(|a| a.len()).unwrap_or(0);
assert_eq!(
br_blocked_count, bd_blocked_count,
"Blocked counts differ: br={}, bd={}",
br_blocked_count, bd_blocked_count
);
assert_eq!(br_blocked_count, 2, "Expected 2 blocked issues (B and C)");
let (br_ready, bd_ready) = ws.run_step(7, &["ready", "--json"]);
assert!(br_ready.status.success());
assert!(bd_ready.status.success());
let br_ready_json = extract_json_payload(&br_ready.stdout);
let bd_ready_json = extract_json_payload(&bd_ready.stdout);
let br_ready_val: Value = serde_json::from_str(&br_ready_json).unwrap_or(Value::Array(vec![]));
let bd_ready_val: Value = serde_json::from_str(&bd_ready_json).unwrap_or(Value::Array(vec![]));
let br_ready_count = br_ready_val.as_array().map(|a| a.len()).unwrap_or(0);
let bd_ready_count = bd_ready_val.as_array().map(|a| a.len()).unwrap_or(0);
assert_eq!(
br_ready_count, bd_ready_count,
"Ready counts differ: br={}, bd={}",
br_ready_count, bd_ready_count
);
assert_eq!(br_ready_count, 1, "Expected 1 ready issue (A)");
ws.flush_both(8);
let diff = ws.compare_jsonl();
ws.write_log();
assert!(diff.matched, "JSONL comparison failed:\n{}", diff.explain());
info!("conformance_workflow_dependency_chain passed");
}
#[test]
fn conformance_workflow_close_with_stats() {
skip_if_no_bd!();
common::init_test_logging();
info!("Starting conformance_workflow_close_with_stats");
let mut ws = WorkflowWorkspace::new("close_with_stats");
ws.init_both();
let (br_c1, bd_c1) = ws.run_step(1, &["create", "Issue 1", "--json"]);
let (br_c2, bd_c2) = ws.run_step(2, &["create", "Issue 2", "--json"]);
let (br_c3, bd_c3) = ws.run_step(3, &["create", "Issue 3", "--json"]);
assert!(br_c1.status.success() && bd_c1.status.success());
assert!(br_c2.status.success() && bd_c2.status.success());
assert!(br_c3.status.success() && bd_c3.status.success());
let br_id1 = WorkflowWorkspace::extract_id(&br_c1.stdout).expect("id1");
let bd_id1 = WorkflowWorkspace::extract_id(&bd_c1.stdout).expect("id1");
let br_id2 = WorkflowWorkspace::extract_id(&br_c2.stdout).expect("id2");
let bd_id2 = WorkflowWorkspace::extract_id(&bd_c2.stdout).expect("id2");
let br_close1 = ws.run_br(&["close", &br_id1]);
let bd_close1 = ws.run_bd(&["close", &bd_id1]);
assert!(br_close1.status.success());
assert!(bd_close1.status.success());
let br_close2 = ws.run_br(&["close", &br_id2]);
let bd_close2 = ws.run_bd(&["close", &bd_id2]);
assert!(br_close2.status.success());
assert!(bd_close2.status.success());
let (br_stats, bd_stats) = ws.run_step(6, &["stats", "--json"]);
assert!(br_stats.status.success());
assert!(bd_stats.status.success());
let br_stats_json = extract_json_payload(&br_stats.stdout);
let bd_stats_json = extract_json_payload(&bd_stats.stdout);
let br_stats_val: Value = serde_json::from_str(&br_stats_json).expect("parse");
let bd_stats_val: Value = serde_json::from_str(&bd_stats_json).expect("parse");
let br_open = br_stats_val
.get("open")
.or_else(|| br_stats_val.get("summary").and_then(|s| s.get("open")));
let bd_open = bd_stats_val
.get("open")
.or_else(|| bd_stats_val.get("summary").and_then(|s| s.get("open")));
let br_closed = br_stats_val
.get("closed")
.or_else(|| br_stats_val.get("summary").and_then(|s| s.get("closed")));
let bd_closed = bd_stats_val
.get("closed")
.or_else(|| bd_stats_val.get("summary").and_then(|s| s.get("closed")));
assert_eq!(
br_open.and_then(|v| v.as_i64()),
bd_open.and_then(|v| v.as_i64()),
"Open counts differ: br={:?}, bd={:?}",
br_open,
bd_open
);
assert_eq!(
br_closed.and_then(|v| v.as_i64()),
bd_closed.and_then(|v| v.as_i64()),
"Closed counts differ: br={:?}, bd={:?}",
br_closed,
bd_closed
);
ws.flush_both(7);
let diff = ws.compare_jsonl();
ws.write_log();
assert!(diff.matched, "JSONL comparison failed:\n{}", diff.explain());
info!("conformance_workflow_close_with_stats passed");
}
#[test]
fn conformance_workflow_delete_lifecycle() {
skip_if_no_bd!();
common::init_test_logging();
info!("Starting conformance_workflow_delete_lifecycle");
let mut ws = WorkflowWorkspace::new("delete_lifecycle");
ws.init_both();
let (br_c1, bd_c1) = ws.run_step(1, &["create", "To be deleted", "--json"]);
let (br_c2, bd_c2) = ws.run_step(2, &["create", "Keep this one", "--json"]);
assert!(br_c1.status.success() && bd_c1.status.success());
assert!(br_c2.status.success() && bd_c2.status.success());
let br_id1 = WorkflowWorkspace::extract_id(&br_c1.stdout).expect("id1");
let bd_id1 = WorkflowWorkspace::extract_id(&bd_c1.stdout).expect("id1");
let br_del = ws.run_br(&["delete", &br_id1, "--reason", "test"]);
let bd_del = ws.run_bd(&["delete", &bd_id1, "--reason", "test", "--force"]);
assert!(
br_del.status.success(),
"br delete failed: {}",
br_del.stderr
);
assert!(
bd_del.status.success(),
"bd delete failed: {}",
bd_del.stderr
);
let (br_list, bd_list) = ws.run_step(4, &["list", "--json"]);
assert!(br_list.status.success());
assert!(bd_list.status.success());
let br_list_json = extract_json_payload(&br_list.stdout);
let bd_list_json = extract_json_payload(&bd_list.stdout);
let br_list_val: Value = serde_json::from_str(&br_list_json).unwrap_or(Value::Array(vec![]));
let bd_list_val: Value = serde_json::from_str(&bd_list_json).unwrap_or(Value::Array(vec![]));
let br_count = br_list_val.as_array().map(|a| a.len()).unwrap_or(0);
let bd_count = bd_list_val.as_array().map(|a| a.len()).unwrap_or(0);
assert_eq!(
br_count, bd_count,
"List counts differ after delete: br={}, bd={}",
br_count, bd_count
);
assert_eq!(br_count, 1, "Expected 1 issue after deletion");
ws.flush_both(5);
let diff = ws.compare_jsonl();
ws.write_log();
assert!(diff.matched, "JSONL comparison failed:\n{}", diff.explain());
info!("conformance_workflow_delete_lifecycle passed");
}
#[test]
fn conformance_workflow_full_lifecycle() {
skip_if_no_bd!();
common::init_test_logging();
info!("Starting conformance_workflow_full_lifecycle");
let mut ws = WorkflowWorkspace::new("full_lifecycle");
ws.init_both();
let (br_epic, bd_epic) = ws.run_step(
1,
&[
"create",
"Epic: New Feature",
"--type",
"epic",
"--priority",
"1",
"--json",
],
);
let (br_task1, bd_task1) = ws.run_step(
2,
&[
"create",
"Task 1: Design",
"--type",
"task",
"--priority",
"2",
"--json",
],
);
let (br_task2, bd_task2) = ws.run_step(
3,
&[
"create",
"Task 2: Implement",
"--type",
"task",
"--priority",
"2",
"--json",
],
);
let (br_bug, bd_bug) = ws.run_step(
4,
&[
"create",
"Bug: Edge case",
"--type",
"bug",
"--priority",
"0",
"--json",
],
);
assert!(br_epic.status.success() && bd_epic.status.success());
assert!(br_task1.status.success() && bd_task1.status.success());
assert!(br_task2.status.success() && bd_task2.status.success());
assert!(br_bug.status.success() && bd_bug.status.success());
let br_epic_id = WorkflowWorkspace::extract_id(&br_epic.stdout).expect("epic id");
let bd_epic_id = WorkflowWorkspace::extract_id(&bd_epic.stdout).expect("epic id");
let br_task1_id = WorkflowWorkspace::extract_id(&br_task1.stdout).expect("task1 id");
let bd_task1_id = WorkflowWorkspace::extract_id(&bd_task1.stdout).expect("task1 id");
let br_task2_id = WorkflowWorkspace::extract_id(&br_task2.stdout).expect("task2 id");
let bd_task2_id = WorkflowWorkspace::extract_id(&bd_task2.stdout).expect("task2 id");
let br_bug_id = WorkflowWorkspace::extract_id(&br_bug.stdout).expect("bug id");
let bd_bug_id = WorkflowWorkspace::extract_id(&bd_bug.stdout).expect("bug id");
ws.run_br(&["dep", "add", &br_task1_id, &br_epic_id]);
ws.run_bd(&["dep", "add", &bd_task1_id, &bd_epic_id]);
ws.run_br(&["dep", "add", &br_task2_id, &br_epic_id]);
ws.run_bd(&["dep", "add", &bd_task2_id, &bd_epic_id]);
ws.run_br(&["update", &br_epic_id, "--status", "in_progress"]);
ws.run_bd(&["update", &bd_epic_id, "--status", "in_progress"]);
ws.run_br(&["close", &br_epic_id]);
ws.run_bd(&["close", &bd_epic_id]);
ws.run_br(&["close", &br_task1_id]);
ws.run_bd(&["close", &bd_task1_id]);
ws.run_br(&["delete", &br_bug_id, "--reason", "no longer relevant"]);
ws.run_bd(&[
"delete",
&bd_bug_id,
"--reason",
"no longer relevant",
"--force",
]);
let (br_list, bd_list) = ws.run_step(12, &["list", "--status=all", "--json"]);
let (br_list_final, bd_list_final) = if !br_list.status.success() {
ws.run_step(12, &["list", "--json"])
} else {
(br_list, bd_list)
};
let br_json = extract_json_payload(&br_list_final.stdout);
let bd_json = extract_json_payload(&bd_list_final.stdout);
let br_val: Value = serde_json::from_str(&br_json).unwrap_or(Value::Array(vec![]));
let bd_val: Value = serde_json::from_str(&bd_json).unwrap_or(Value::Array(vec![]));
let br_count = br_val.as_array().map(|a| a.len()).unwrap_or(0);
let bd_count = bd_val.as_array().map(|a| a.len()).unwrap_or(0);
assert_eq!(
br_count, bd_count,
"Final list counts differ: br={}, bd={}",
br_count, bd_count
);
ws.flush_both(13);
let diff = ws.compare_jsonl();
ws.write_log();
if !diff.matched {
eprintln!("JSONL differences found:\n{}", diff.explain());
}
assert!(diff.matched, "JSONL comparison failed:\n{}", diff.explain());
info!("conformance_workflow_full_lifecycle passed");
}
#[test]
fn conformance_workflow_dep_removal() {
skip_if_no_bd!();
common::init_test_logging();
info!("Starting conformance_workflow_dep_removal");
let mut ws = WorkflowWorkspace::new("dep_removal");
ws.init_both();
let (br_c1, bd_c1) = ws.run_step(1, &["create", "Blocker issue", "--json"]);
let (br_c2, bd_c2) = ws.run_step(2, &["create", "Blocked issue", "--json"]);
assert!(br_c1.status.success() && bd_c1.status.success());
assert!(br_c2.status.success() && bd_c2.status.success());
let br_id1 = WorkflowWorkspace::extract_id(&br_c1.stdout).expect("id1");
let bd_id1 = WorkflowWorkspace::extract_id(&bd_c1.stdout).expect("id1");
let br_id2 = WorkflowWorkspace::extract_id(&br_c2.stdout).expect("id2");
let bd_id2 = WorkflowWorkspace::extract_id(&bd_c2.stdout).expect("id2");
let br_add = ws.run_br(&["dep", "add", &br_id2, &br_id1]);
let bd_add = ws.run_bd(&["dep", "add", &bd_id2, &bd_id1]);
assert!(br_add.status.success());
assert!(bd_add.status.success());
let (br_blocked1, bd_blocked1) = ws.run_step(4, &["blocked", "--json"]);
let br_blocked1_val: Value = serde_json::from_str(&extract_json_payload(&br_blocked1.stdout))
.unwrap_or(Value::Array(vec![]));
let bd_blocked1_val: Value = serde_json::from_str(&extract_json_payload(&bd_blocked1.stdout))
.unwrap_or(Value::Array(vec![]));
let br_blocked1_count = br_blocked1_val.as_array().map(|a| a.len()).unwrap_or(0);
let bd_blocked1_count = bd_blocked1_val.as_array().map(|a| a.len()).unwrap_or(0);
assert_eq!(br_blocked1_count, bd_blocked1_count);
assert_eq!(
br_blocked1_count, 1,
"Expected 1 blocked issue before removal"
);
let br_rm = ws.run_br(&["dep", "rm", &br_id2, &br_id1]);
let bd_rm = ws.run_bd(&["dep", "rm", &bd_id2, &bd_id1]);
assert!(br_rm.status.success(), "br dep rm failed: {}", br_rm.stderr);
assert!(bd_rm.status.success(), "bd dep rm failed: {}", bd_rm.stderr);
let (br_blocked2, bd_blocked2) = ws.run_step(6, &["blocked", "--json"]);
let br_blocked2_val: Value = serde_json::from_str(&extract_json_payload(&br_blocked2.stdout))
.unwrap_or(Value::Array(vec![]));
let bd_blocked2_val: Value = serde_json::from_str(&extract_json_payload(&bd_blocked2.stdout))
.unwrap_or(Value::Array(vec![]));
let br_blocked2_count = br_blocked2_val.as_array().map(|a| a.len()).unwrap_or(0);
let bd_blocked2_count = bd_blocked2_val.as_array().map(|a| a.len()).unwrap_or(0);
assert_eq!(br_blocked2_count, bd_blocked2_count);
assert_eq!(
br_blocked2_count, 0,
"Expected 0 blocked issues after removal"
);
ws.flush_both(7);
let diff = ws.compare_jsonl();
ws.write_log();
assert!(diff.matched, "JSONL comparison failed:\n{}", diff.explain());
info!("conformance_workflow_dep_removal passed");
}
#[test]
fn conformance_workflow_sequential_updates() {
skip_if_no_bd!();
common::init_test_logging();
info!("Starting conformance_workflow_sequential_updates");
let mut ws = WorkflowWorkspace::new("sequential_updates");
ws.init_both();
let (br_c, bd_c) = ws.run_step(
1,
&[
"create",
"Evolving issue",
"--type",
"task",
"--priority",
"3",
"--json",
],
);
assert!(br_c.status.success() && bd_c.status.success());
let br_id = WorkflowWorkspace::extract_id(&br_c.stdout).expect("id");
let bd_id = WorkflowWorkspace::extract_id(&bd_c.stdout).expect("id");
ws.run_br(&["update", &br_id, "--priority", "2"]);
ws.run_bd(&["update", &bd_id, "--priority", "2"]);
ws.run_br(&["update", &br_id, "--status", "in_progress"]);
ws.run_bd(&["update", &bd_id, "--status", "in_progress"]);
ws.run_br(&["update", &br_id, "--priority", "1"]);
ws.run_bd(&["update", &bd_id, "--priority", "1"]);
ws.run_br(&["update", &br_id, "--type", "bug"]);
ws.run_bd(&["update", &bd_id, "--type", "bug"]);
let br_show = ws.run_br(&["show", &br_id, "--json"]);
let bd_show = ws.run_bd(&["show", &bd_id, "--json"]);
assert!(br_show.status.success());
assert!(bd_show.status.success());
let br_show_json = extract_json_payload(&br_show.stdout);
let bd_show_json = extract_json_payload(&bd_show.stdout);
let br_val: Value = serde_json::from_str(&br_show_json).expect("parse");
let bd_val: Value = serde_json::from_str(&bd_show_json).expect("parse");
let br_issue = if br_val.is_array() {
br_val[0].clone()
} else {
br_val.clone()
};
let bd_issue = if bd_val.is_array() {
bd_val[0].clone()
} else {
bd_val.clone()
};
assert_eq!(
br_issue.get("priority").and_then(|v| v.as_i64()),
bd_issue.get("priority").and_then(|v| v.as_i64()),
"Priority mismatch: br={:?}, bd={:?}",
br_issue.get("priority"),
bd_issue.get("priority")
);
assert_eq!(
br_issue.get("status").and_then(|v| v.as_str()),
bd_issue.get("status").and_then(|v| v.as_str()),
"Status mismatch"
);
assert_eq!(
br_issue.get("type").and_then(|v| v.as_str()),
bd_issue.get("type").and_then(|v| v.as_str()),
"Type mismatch"
);
ws.flush_both(7);
let diff = ws.compare_jsonl();
ws.write_log();
assert!(diff.matched, "JSONL comparison failed:\n{}", diff.explain());
info!("conformance_workflow_sequential_updates passed");
}
#[test]
fn conformance_workflow_assignee_changes() {
skip_if_no_bd!();
common::init_test_logging();
info!("Starting conformance_workflow_assignee_changes");
let mut ws = WorkflowWorkspace::new("assignee_changes");
ws.init_both();
let (br_c, bd_c) = ws.run_step(
1,
&["create", "Assigned task", "--assignee", "alice", "--json"],
);
assert!(br_c.status.success() && bd_c.status.success());
let br_id = WorkflowWorkspace::extract_id(&br_c.stdout).expect("id");
let bd_id = WorkflowWorkspace::extract_id(&bd_c.stdout).expect("id");
let br_u1 = ws.run_br(&["update", &br_id, "--assignee", "bob"]);
let bd_u1 = ws.run_bd(&["update", &bd_id, "--assignee", "bob"]);
assert!(br_u1.status.success());
assert!(bd_u1.status.success());
let br_show = ws.run_br(&["show", &br_id, "--json"]);
let bd_show = ws.run_bd(&["show", &bd_id, "--json"]);
let br_val: Value =
serde_json::from_str(&extract_json_payload(&br_show.stdout)).expect("parse");
let bd_val: Value =
serde_json::from_str(&extract_json_payload(&bd_show.stdout)).expect("parse");
let br_issue = if br_val.is_array() {
&br_val[0]
} else {
&br_val
};
let bd_issue = if bd_val.is_array() {
&bd_val[0]
} else {
&bd_val
};
assert_eq!(
br_issue.get("assignee").and_then(|v| v.as_str()),
bd_issue.get("assignee").and_then(|v| v.as_str()),
"Assignee mismatch: br={:?}, bd={:?}",
br_issue.get("assignee"),
bd_issue.get("assignee")
);
ws.flush_both(4);
let diff = ws.compare_jsonl();
ws.write_log();
assert!(diff.matched, "JSONL comparison failed:\n{}", diff.explain());
info!("conformance_workflow_assignee_changes passed");
}