#![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::cell::Cell;
use std::collections::HashSet;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
use tracing::info;
fn get_bd_binary() -> String {
common::bd_binary_name()
}
macro_rules! skip_if_no_bd {
() => {
if let Some(reason) = common::bd_skip_reason() {
eprintln!("Skipping conformance test: {reason}");
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",
"source_repo_path",
"thread_id",
"metadata",
];
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 input_errors: Vec<String>,
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();
for error in &self.input_errors {
parts.push(format!("Input error: {error}"));
}
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 issue_count(value: &Value) -> usize {
value.as_array().map_or_else(
|| {
value
.get("issues")
.and_then(Value::as_array)
.map_or(0, Vec::len)
},
Vec::len,
)
}
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_entries, bd_entries) = match (
read_comparison_jsonl(br_path),
read_comparison_jsonl(bd_path),
) {
(Ok(br), Ok(bd)) => (br, bd),
(br, bd) => {
result
.input_errors
.extend([br.err(), bd.err()].into_iter().flatten());
return result;
}
};
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
}
fn read_comparison_jsonl(path: &Path) -> Result<Vec<Value>, String> {
let content = fs::read_to_string(path)
.map_err(|error| format!("{}: cannot read JSONL: {error}", path.display()))?;
content
.lines()
.enumerate()
.filter(|(_, line)| !line.trim().is_empty())
.map(|(index, line)| {
serde_json::from_str(line).map_err(|error| {
format!("{}:{}: invalid JSONL: {error}", path.display(), index + 1)
})
})
.collect()
}
#[test]
fn workflow_jsonl_control_refuses_missing_unreadable_and_malformed_inputs() {
let root = TempDir::new_in(common::cli::isolated_temp_root()).unwrap();
let baseline = root.path().join("baseline.jsonl");
let candidate = root.path().join("candidate.jsonl");
let missing = compare_jsonl_files(&baseline, &candidate);
assert!(!missing.matched);
assert_eq!(missing.input_errors.len(), 2);
assert!(missing.explain().contains(baseline.to_str().unwrap()));
assert!(missing.explain().contains(candidate.to_str().unwrap()));
fs::write(&baseline, "").unwrap();
fs::write(&candidate, "").unwrap();
assert!(compare_jsonl_files(&baseline, &candidate).matched);
let unreadable = root.path().join("directory.jsonl");
fs::create_dir(&unreadable).unwrap();
let diff = compare_jsonl_files(&baseline, &unreadable);
assert!(!diff.matched);
assert!(diff.explain().contains(unreadable.to_str().unwrap()));
assert!(diff.explain().contains("cannot read JSONL"));
fs::write(&baseline, "{\"title\":\"same\"}\n").unwrap();
fs::write(&candidate, "{\"title\":\"same\"}\n").unwrap();
assert!(compare_jsonl_files(&baseline, &candidate).matched);
fs::write(&candidate, "{\"title\":\"same\"}\nnot JSON\n").unwrap();
let diff = compare_jsonl_files(&baseline, &candidate);
assert!(!diff.matched);
assert!(diff.explain().contains("candidate.jsonl:2: invalid JSONL"));
fs::write(&baseline, "not JSON\n").unwrap();
fs::write(&candidate, "not JSON\n").unwrap();
let diff = compare_jsonl_files(&baseline, &candidate);
assert!(!diff.matched);
assert_eq!(diff.input_errors.len(), 2);
}
pub struct WorkflowWorkspace {
pub temp_dir: Option<TempDir>,
pub br_root: std::path::PathBuf,
pub bd_root: std::path::PathBuf,
pub log_dir: std::path::PathBuf,
pub workflow_log: Vec<WorkflowStep>,
command_sequence: Cell<usize>,
}
impl Drop for WorkflowWorkspace {
fn drop(&mut self) {
if std::thread::panicking()
&& let Some(temp_dir) = self.temp_dir.take()
{
eprintln!(
"Workflow failure workspace retained: {}",
temp_dir.keep().display()
);
}
}
}
#[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_in(common::cli::isolated_temp_root()).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: Some(temp_dir),
br_root,
bd_root,
log_dir,
workflow_log: Vec::new(),
command_sequence: Cell::new(0),
}
}
pub fn init_both(&mut self) {
let (br, bd) = self.run_step(0, &["init", "--prefix", "bd"]);
assert!(
br.status.success() && bd.status.success(),
"workflow init failed; commands retained in {}\nbr status: {}\nstdout:\n{}\nstderr:\n{}\nbd status: {}\nstdout:\n{}\nstderr:\n{}",
self.log_dir.display(),
br.status,
br.stdout,
br.stderr,
bd.status,
bd.stdout,
bd.stderr,
);
}
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 binary = assert_cmd::cargo::cargo_bin!("br");
let mut cmd = std::process::Command::new(binary);
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();
self.record_command("br", binary, &self.br_root, args, &output);
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 binary = get_bd_binary();
let mut cmd = std::process::Command::new(&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();
self.record_command("bd", Path::new(&binary), &self.bd_root, args, &output);
CmdOutput {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
status: output.status,
duration,
}
}
fn record_command(
&self,
side: &str,
binary: &Path,
workspace: &Path,
args: &[&str],
output: &std::process::Output,
) {
let sequence = self.command_sequence.get();
self.command_sequence.set(sequence + 1);
let stem = format!("{sequence:04}-{side}");
let stdout_file = format!("{stem}.stdout");
let stderr_file = format!("{stem}.stderr");
fs::write(self.log_dir.join(&stdout_file), &output.stdout).expect("retain command stdout");
fs::write(self.log_dir.join(&stderr_file), &output.stderr).expect("retain command stderr");
let record = serde_json::json!({
"program": binary,
"argv": args,
"cwd": workspace,
"status": output.status.to_string(),
"exit_code": output.status.code(),
"success": output.status.success(),
"stdout_file": stdout_file,
"stderr_file": stderr_file,
});
fs::write(
self.log_dir.join(format!("{stem}.json")),
serde_json::to_vec_pretty(&record).expect("serialize command evidence"),
)
.expect("retain command evidence");
}
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).expect("serialize workflow log");
fs::write(&log_path, json).expect("retain workflow log");
}
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
}
}
#[test]
fn workflow_failure_control_retains_failed_init_and_complete_command_evidence() {
skip_if_no_bd!();
let mut workspace = WorkflowWorkspace::new("failed_init_control");
let root = workspace.temp_dir.as_ref().unwrap().path().to_path_buf();
fs::write(
workspace.br_root.join(".beads"),
"invalid directory control",
)
.unwrap();
let failed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
workspace.init_both();
}));
assert!(
failed.is_err(),
"invalid initialization must fail before any workflow step"
);
let panic = failed.unwrap_err();
let message = panic
.downcast_ref::<String>()
.expect("diagnostic init panic");
assert!(message.contains("workflow init failed"));
assert!(root.is_dir(), "failed workspace was lost during unwinding");
for (stem, expected_success) in [("0000-br", false), ("0001-bd", true)] {
let record: Value = serde_json::from_slice(
&fs::read(root.join("logs").join(format!("{stem}.json"))).unwrap(),
)
.unwrap();
assert_eq!(
record["argv"],
serde_json::json!(["init", "--prefix", "bd"])
);
assert_eq!(record["success"], expected_success);
assert_eq!(record["exit_code"].as_i64() == Some(0), expected_success);
assert!(!record["program"].as_str().unwrap().is_empty());
assert!(!record["status"].as_str().unwrap().is_empty());
let stdout = fs::read(
root.join("logs")
.join(record["stdout_file"].as_str().unwrap()),
)
.unwrap();
let stderr = fs::read(
root.join("logs")
.join(record["stderr_file"].as_str().unwrap()),
)
.unwrap();
assert!(
!stdout.is_empty() || !stderr.is_empty(),
"missing actual command output"
);
if !expected_success {
assert_eq!(record["exit_code"], 8);
assert_eq!(
record["cwd"].as_str().unwrap(),
root.join("br_workspace").to_str().unwrap()
);
let stderr = String::from_utf8_lossy(&stderr);
assert!(stderr.contains("I/O error"));
assert!(message.contains(stderr.as_ref()));
assert_eq!(
fs::read_to_string(root.join("br_workspace/.beads")).unwrap(),
"invalid directory control"
);
}
}
assert!(root.join("bd_workspace/.beads/beads.db").is_file());
}
#[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 = issue_count(&br_val);
let bd_count = issue_count(&bd_val);
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 = issue_count(&br_blocked_val);
let bd_blocked_count = issue_count(&bd_blocked_val);
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 = issue_count(&br_ready_val);
let bd_ready_count = issue_count(&bd_ready_val);
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 = issue_count(&br_list_val);
let bd_count = issue_count(&bd_list_val);
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 = issue_count(&br_val);
let bd_count = issue_count(&bd_val);
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 = issue_count(&br_blocked1_val);
let bd_blocked1_count = issue_count(&bd_blocked1_val);
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 = issue_count(&br_blocked2_val);
let bd_blocked2_count = issue_count(&bd_blocked2_val);
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");
}