#![allow(clippy::all, clippy::pedantic, clippy::nursery, dead_code)]
mod common;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use std::time::{Duration, Instant};
use tempfile::TempDir;
use tracing::info;
#[derive(Debug)]
pub struct CmdOutput {
pub stdout: String,
pub stderr: String,
pub success: bool,
pub duration: Duration,
}
pub struct SchemaWorkspace {
pub temp_dir: TempDir,
pub br_root: PathBuf,
pub bd_root: PathBuf,
}
impl SchemaWorkspace {
pub fn new() -> 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");
fs::create_dir_all(&br_root).expect("create br workspace");
fs::create_dir_all(&bd_root).expect("create bd workspace");
Self {
temp_dir,
br_root,
bd_root,
}
}
pub fn init_both(&self) {
let br_out = self.run_br(&["init"]);
assert!(br_out.success, "br init failed: {}", br_out.stderr);
let bd_out = self.run_bd(&["init"]);
assert!(bd_out.success, "bd init failed: {}", bd_out.stderr);
}
pub fn run_br(&self, args: &[&str]) -> CmdOutput {
run_binary("br", &self.br_root, args)
}
pub fn run_bd(&self, args: &[&str]) -> CmdOutput {
run_binary("bd", &self.bd_root, args)
}
pub fn br_db_path(&self) -> PathBuf {
self.br_root.join(".beads").join("beads.db")
}
pub fn bd_db_path(&self) -> PathBuf {
self.bd_root.join(".beads").join("beads.db")
}
pub fn br_metadata_path(&self) -> PathBuf {
self.br_root.join(".beads").join("metadata.json")
}
pub fn bd_metadata_path(&self) -> PathBuf {
self.bd_root.join(".beads").join("metadata.json")
}
pub fn br_jsonl_path(&self) -> PathBuf {
self.br_root.join(".beads").join("issues.jsonl")
}
pub fn bd_jsonl_path(&self) -> PathBuf {
self.bd_root.join(".beads").join("issues.jsonl")
}
pub fn run_sqlite_pragma(&self, db_path: &PathBuf, pragma: &str) -> String {
let output = Command::new("sqlite3")
.args([db_path.to_str().unwrap(), pragma])
.output()
.expect("run sqlite3");
String::from_utf8_lossy(&output.stdout).to_string()
}
}
fn binaries_available() -> bool {
let br_exists = assert_cmd::cargo::cargo_bin!("br").exists();
let bd_available = Command::new("bd")
.arg("version")
.output()
.is_ok_and(|o| o.status.success());
br_exists && bd_available
}
macro_rules! skip_if_no_binaries {
() => {
if !binaries_available() {
eprintln!("Skipping test: 'br' release binary or 'bd' not found (expected in CI)");
return;
}
};
}
fn run_binary(binary: &str, cwd: &PathBuf, args: &[&str]) -> CmdOutput {
let cmd_path: PathBuf = if binary == "br" {
assert_cmd::cargo::cargo_bin!("br").to_path_buf()
} else {
PathBuf::from(binary)
};
let start = Instant::now();
let output = Command::new(&cmd_path)
.current_dir(cwd)
.args(args)
.env("NO_COLOR", "1")
.env("HOME", cwd)
.output()
.expect(&format!("run {binary}"));
let duration = start.elapsed();
CmdOutput {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
success: output.status.success(),
duration,
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct ColumnInfo {
pub cid: i32,
pub name: String,
pub col_type: String,
pub notnull: bool,
pub dflt_value: Option<String>,
pub pk: bool,
}
impl ColumnInfo {
pub fn from_pragma_line(line: &str) -> Option<Self> {
let parts: Vec<&str> = line.split('|').collect();
if parts.len() < 6 {
return None;
}
Some(Self {
cid: parts[0].parse().unwrap_or(0),
name: parts[1].to_string(),
col_type: parts[2].to_string(),
notnull: parts[3] == "1",
dflt_value: if parts[4].is_empty() {
None
} else {
Some(parts[4].to_string())
},
pk: parts[5] != "0",
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct IndexInfo {
pub name: String,
pub unique: bool,
pub columns: Vec<String>,
}
#[derive(Debug, Default)]
pub struct SchemaComparison {
pub tables_br_only: Vec<String>,
pub tables_bd_only: Vec<String>,
pub tables_common: Vec<String>,
pub column_diffs: Vec<ColumnDiff>,
pub index_diffs: Vec<IndexDiff>,
}
#[derive(Debug)]
pub struct ColumnDiff {
pub table: String,
pub column: String,
pub diff_type: String,
pub br_value: String,
pub bd_value: String,
}
#[derive(Debug)]
pub struct IndexDiff {
pub table: String,
pub index: String,
pub diff_type: String,
pub br_value: String,
pub bd_value: String,
}
impl SchemaComparison {
pub fn is_compatible(&self) -> bool {
self.tables_bd_only.is_empty() && self.column_diffs.is_empty()
}
pub fn summary(&self) -> String {
let mut lines = Vec::new();
if !self.tables_br_only.is_empty() {
lines.push(format!("Tables in br only: {:?}", self.tables_br_only));
}
if !self.tables_bd_only.is_empty() {
lines.push(format!("Tables in bd only: {:?}", self.tables_bd_only));
}
for diff in &self.column_diffs {
lines.push(format!(
"Column diff in {}.{}: {} (br: {}, bd: {})",
diff.table, diff.column, diff.diff_type, diff.br_value, diff.bd_value
));
}
for diff in &self.index_diffs {
lines.push(format!(
"Index diff in {}.{}: {} (br: {}, bd: {})",
diff.table, diff.index, diff.diff_type, diff.br_value, diff.bd_value
));
}
if lines.is_empty() {
"Schema compatible".to_string()
} else {
lines.join("\n")
}
}
}
fn get_table_list(workspace: &SchemaWorkspace, db_path: &PathBuf) -> Vec<String> {
let output = workspace.run_sqlite_pragma(
db_path,
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;",
);
output
.lines()
.filter(|l| !l.is_empty())
.map(|l| l.trim().to_string())
.collect()
}
fn get_table_columns(
workspace: &SchemaWorkspace,
db_path: &PathBuf,
table: &str,
) -> Vec<ColumnInfo> {
let output = workspace.run_sqlite_pragma(db_path, &format!("PRAGMA table_info({table});"));
output
.lines()
.filter_map(ColumnInfo::from_pragma_line)
.collect()
}
fn get_table_indexes(
workspace: &SchemaWorkspace,
db_path: &PathBuf,
table: &str,
) -> Vec<IndexInfo> {
let list_output = workspace.run_sqlite_pragma(db_path, &format!("PRAGMA index_list({table});"));
let mut indexes = Vec::new();
for line in list_output.lines() {
let parts: Vec<&str> = line.split('|').collect();
if parts.len() < 3 {
continue;
}
let index_name = parts[1];
let unique = parts[2] == "1";
let info_output =
workspace.run_sqlite_pragma(db_path, &format!("PRAGMA index_info({index_name});"));
let columns: Vec<String> = info_output
.lines()
.filter_map(|l| {
let cols: Vec<&str> = l.split('|').collect();
if cols.len() >= 3 {
Some(cols[2].to_string())
} else {
None
}
})
.collect();
indexes.push(IndexInfo {
name: index_name.to_string(),
unique,
columns,
});
}
indexes
}
fn compare_schemas(workspace: &SchemaWorkspace) -> SchemaComparison {
let br_db = workspace.br_db_path();
let bd_db = workspace.bd_db_path();
let br_tables: BTreeSet<String> = get_table_list(workspace, &br_db).into_iter().collect();
let bd_tables: BTreeSet<String> = get_table_list(workspace, &bd_db).into_iter().collect();
let mut result = SchemaComparison::default();
result.tables_br_only = br_tables.difference(&bd_tables).cloned().collect();
result.tables_bd_only = bd_tables.difference(&br_tables).cloned().collect();
result.tables_common = br_tables.intersection(&bd_tables).cloned().collect();
for table in &result.tables_common {
if table.starts_with("sqlite_") {
continue;
}
let br_columns = get_table_columns(workspace, &br_db, table);
let bd_columns = get_table_columns(workspace, &bd_db, table);
let br_col_map: BTreeMap<String, ColumnInfo> = br_columns
.into_iter()
.map(|c| (c.name.clone(), c))
.collect();
let bd_col_map: BTreeMap<String, ColumnInfo> = bd_columns
.into_iter()
.map(|c| (c.name.clone(), c))
.collect();
let br_names: BTreeSet<String> = br_col_map.keys().cloned().collect();
let bd_names: BTreeSet<String> = bd_col_map.keys().cloned().collect();
for name in br_names.difference(&bd_names) {
result.column_diffs.push(ColumnDiff {
table: table.clone(),
column: name.clone(),
diff_type: "missing_in_bd".to_string(),
br_value: format!("{:?}", br_col_map.get(name)),
bd_value: "None".to_string(),
});
}
for name in bd_names.difference(&br_names) {
result.column_diffs.push(ColumnDiff {
table: table.clone(),
column: name.clone(),
diff_type: "missing_in_br".to_string(),
br_value: "None".to_string(),
bd_value: format!("{:?}", bd_col_map.get(name)),
});
}
for name in br_names.intersection(&bd_names) {
let br_col = br_col_map.get(name).unwrap();
let bd_col = bd_col_map.get(name).unwrap();
let br_type = normalize_sqlite_type(&br_col.col_type);
let bd_type = normalize_sqlite_type(&bd_col.col_type);
if br_type != bd_type {
result.column_diffs.push(ColumnDiff {
table: table.clone(),
column: name.clone(),
diff_type: "type_mismatch".to_string(),
br_value: br_col.col_type.clone(),
bd_value: bd_col.col_type.clone(),
});
}
if br_col.notnull != bd_col.notnull {
result.column_diffs.push(ColumnDiff {
table: table.clone(),
column: name.clone(),
diff_type: "notnull_mismatch".to_string(),
br_value: br_col.notnull.to_string(),
bd_value: bd_col.notnull.to_string(),
});
}
if br_col.pk != bd_col.pk {
result.column_diffs.push(ColumnDiff {
table: table.clone(),
column: name.clone(),
diff_type: "pk_mismatch".to_string(),
br_value: br_col.pk.to_string(),
bd_value: bd_col.pk.to_string(),
});
}
}
let br_indexes = get_table_indexes(workspace, &br_db, table);
let bd_indexes = get_table_indexes(workspace, &bd_db, table);
let br_idx_map: BTreeMap<String, IndexInfo> = br_indexes
.into_iter()
.map(|i| (i.name.clone(), i))
.collect();
let bd_idx_map: BTreeMap<String, IndexInfo> = bd_indexes
.into_iter()
.map(|i| (i.name.clone(), i))
.collect();
let br_idx_by_cols: BTreeSet<Vec<String>> =
br_idx_map.values().map(|i| i.columns.clone()).collect();
let bd_idx_by_cols: BTreeSet<Vec<String>> =
bd_idx_map.values().map(|i| i.columns.clone()).collect();
for cols in br_idx_by_cols.difference(&bd_idx_by_cols) {
result.index_diffs.push(IndexDiff {
table: table.clone(),
index: cols.join(","),
diff_type: "index_missing_in_bd".to_string(),
br_value: format!("{:?}", cols),
bd_value: "None".to_string(),
});
}
for cols in bd_idx_by_cols.difference(&br_idx_by_cols) {
result.index_diffs.push(IndexDiff {
table: table.clone(),
index: cols.join(","),
diff_type: "index_missing_in_br".to_string(),
br_value: "None".to_string(),
bd_value: format!("{:?}", cols),
});
}
}
result
}
fn normalize_sqlite_type(t: &str) -> String {
let upper = t.to_uppercase();
match upper.as_str() {
"INTEGER" | "INT" | "BIGINT" | "SMALLINT" | "TINYINT" => "INTEGER".to_string(),
"TEXT" | "VARCHAR" | "CHAR" | "CLOB" => "TEXT".to_string(),
"REAL" | "DOUBLE" | "FLOAT" => "REAL".to_string(),
"BLOB" => "BLOB".to_string(),
"BOOLEAN" | "BOOL" => "INTEGER".to_string(), "" => "".to_string(),
_ => upper,
}
}
fn compare_metadata(workspace: &SchemaWorkspace) -> Result<(), String> {
let br_meta = workspace.br_metadata_path();
let bd_meta = workspace.bd_metadata_path();
let br_content = fs::read_to_string(&br_meta)
.map_err(|e| format!("Failed to read br metadata.json: {e}"))?;
let bd_content = fs::read_to_string(&bd_meta)
.map_err(|e| format!("Failed to read bd metadata.json: {e}"))?;
let br_json: Value =
serde_json::from_str(&br_content).map_err(|e| format!("br metadata.json invalid: {e}"))?;
let bd_json: Value =
serde_json::from_str(&bd_content).map_err(|e| format!("bd metadata.json invalid: {e}"))?;
let required_fields = ["database", "jsonl_export"];
for field in required_fields {
let br_val = br_json.get(field);
let bd_val = bd_json.get(field);
if br_val.is_none() && bd_val.is_some() {
return Err(format!(
"metadata.json field '{}' missing in br but present in bd",
field
));
}
if br_val.is_some() && bd_val.is_none() {
continue;
}
if br_val != bd_val {
return Err(format!(
"metadata.json field '{}' differs: br={:?}, bd={:?}",
field, br_val, bd_val
));
}
}
if let (Value::Object(br_obj), Value::Object(bd_obj)) = (&br_json, &bd_json) {
let br_keys: HashSet<&String> = br_obj.keys().collect();
let bd_keys: HashSet<&String> = bd_obj.keys().collect();
let bd_only: Vec<&&String> = bd_keys.difference(&br_keys).collect();
if !bd_only.is_empty() {
return Err(format!(
"metadata.json has fields in bd not in br: {:?}",
bd_only
));
}
}
Ok(())
}
const KNOWN_JSONL_BD_ONLY_FIELDS: &[&str] = &[
"created_by", ];
fn compare_jsonl_fields(workspace: &SchemaWorkspace) -> Result<(), String> {
let br_jsonl = workspace.br_jsonl_path();
let bd_jsonl = workspace.bd_jsonl_path();
let br_content = fs::read_to_string(&br_jsonl)
.map_err(|e| format!("Failed to read br issues.jsonl: {e}"))?;
let bd_content = fs::read_to_string(&bd_jsonl)
.map_err(|e| format!("Failed to read bd issues.jsonl: {e}"))?;
let br_line = br_content
.lines()
.next()
.ok_or("br issues.jsonl is empty")?;
let bd_line = bd_content
.lines()
.next()
.ok_or("bd issues.jsonl is empty")?;
let br_issue: Value =
serde_json::from_str(br_line).map_err(|e| format!("br JSONL line invalid: {e}"))?;
let bd_issue: Value =
serde_json::from_str(bd_line).map_err(|e| format!("bd JSONL line invalid: {e}"))?;
if let (Value::Object(br_obj), Value::Object(bd_obj)) = (&br_issue, &bd_issue) {
let br_keys: BTreeSet<&String> = br_obj.keys().collect();
let bd_keys: BTreeSet<&String> = bd_obj.keys().collect();
let br_only: Vec<&&String> = br_keys.difference(&bd_keys).collect();
if !br_only.is_empty() {
info!("JSONL fields in br but not bd: {:?}", br_only);
}
let bd_only: Vec<&&String> = bd_keys.difference(&br_keys).collect();
let unexpected_bd_only: Vec<&&&String> = bd_only
.iter()
.filter(|f| !KNOWN_JSONL_BD_ONLY_FIELDS.contains(&f.as_str()))
.collect();
for field in &bd_only {
if KNOWN_JSONL_BD_ONLY_FIELDS.contains(&field.as_str()) {
info!(
"Known JSONL difference: field '{}' in bd but not br (skip_serializing_if)",
field
);
}
}
if !unexpected_bd_only.is_empty() {
return Err(format!(
"JSONL fields in bd but not br (feature parity issue): {:?}",
unexpected_bd_only
));
}
for key in br_keys.intersection(&bd_keys) {
let br_val = br_obj.get(*key).unwrap();
let bd_val = bd_obj.get(*key).unwrap();
if !values_structurally_compatible(br_val, bd_val) {
return Err(format!(
"JSONL field '{}' has incompatible types: br={}, bd={}",
key,
value_type_name(br_val),
value_type_name(bd_val)
));
}
}
}
Ok(())
}
fn values_structurally_compatible(a: &Value, b: &Value) -> bool {
match (a, b) {
(Value::Null, Value::Null) => true,
(Value::Null, _) | (_, Value::Null) => true, (Value::Bool(_), Value::Bool(_)) => true,
(Value::Number(_), Value::Number(_)) => true,
(Value::String(_), Value::String(_)) => true,
(Value::Array(_), Value::Array(_)) => true,
(Value::Object(_), Value::Object(_)) => true,
_ => false,
}
}
fn value_type_name(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Bool(_) => "bool",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
const KNOWN_BD_ONLY_TABLES: &[&str] = &[
"compaction_snapshots", "issue_snapshots", "repo_mtimes", ];
const KNOWN_BD_ONLY_COLUMNS: &[&str] = &[
"actor", "crystallizes", "event_kind", "gate_status", "hook_name", "hook_bead", "role_bead", "agent_state", "last_activity", "role_type", "rig", "await_type", "await_id", "timeout", "timeout_ns", "waiters", "holder", "creator", "mol_type", "molecule_id", "payload", "priority_origin", "quality_score", "retry_count", "target", "work_type", ];
const KNOWN_BR_ONLY_COLUMNS: &[&str] = &[
"source_repo",
];
const KNOWN_TYPE_DIFFERENCES: &[&str] = &[
"closed_at",
"compacted_at",
"created_at",
"defer_until",
"deleted_at",
"due_at",
"updated_at",
];
const KNOWN_NOTNULL_DIFFERENCES: &[&str] = &[
"close_reason",
"closed_by_session",
"created_by",
"delete_reason",
"deleted_by",
"original_type",
"owner",
"sender",
"source_system",
];
#[test]
fn conformance_schema_tables_present() {
skip_if_no_binaries!();
common::init_test_logging();
info!("Starting conformance_schema_tables_present test");
let workspace = SchemaWorkspace::new();
workspace.init_both();
let comparison = compare_schemas(&workspace);
info!("Schema comparison: {}", comparison.summary());
info!("Tables in br only: {:?}", comparison.tables_br_only);
info!("Tables in bd only: {:?}", comparison.tables_bd_only);
let unexpected_bd_tables: Vec<&String> = comparison
.tables_bd_only
.iter()
.filter(|t| !KNOWN_BD_ONLY_TABLES.contains(&t.as_str()))
.collect();
assert!(
unexpected_bd_tables.is_empty(),
"bd has unexpected tables not in br (not in known list): {:?}\nKnown differences: {:?}",
unexpected_bd_tables,
KNOWN_BD_ONLY_TABLES
);
for known in KNOWN_BD_ONLY_TABLES {
if comparison.tables_bd_only.contains(&known.to_string()) {
info!(
"Known schema difference: table '{}' in bd but not br (tracked)",
known
);
}
}
let required_tables = [
"issues",
"dependencies",
"labels",
"comments",
"events",
"config",
"metadata",
];
for table in required_tables {
assert!(
comparison.tables_common.contains(&table.to_string()),
"Required table '{}' not in both br and bd",
table
);
}
}
#[test]
fn conformance_schema_issues_columns() {
skip_if_no_binaries!();
common::init_test_logging();
info!("Starting conformance_schema_issues_columns test");
let workspace = SchemaWorkspace::new();
workspace.init_both();
let br_db = workspace.br_db_path();
let bd_db = workspace.bd_db_path();
let br_cols = get_table_columns(&workspace, &br_db, "issues");
let bd_cols = get_table_columns(&workspace, &bd_db, "issues");
let core_columns = [
"id",
"title",
"description",
"status",
"priority",
"issue_type",
"assignee",
"owner",
"created_at",
"updated_at",
"closed_at",
];
let br_names: HashSet<String> = br_cols.iter().map(|c| c.name.clone()).collect();
let bd_names: HashSet<String> = bd_cols.iter().map(|c| c.name.clone()).collect();
for col in core_columns {
assert!(
br_names.contains(col),
"Core column '{}' missing in br issues table",
col
);
assert!(
bd_names.contains(col),
"Core column '{}' missing in bd issues table",
col
);
}
let bd_only: Vec<&String> = bd_names.difference(&br_names).collect();
let unexpected_bd_cols: Vec<&&String> = bd_only
.iter()
.filter(|c| !KNOWN_BD_ONLY_COLUMNS.contains(&c.as_str()))
.collect();
for col in &bd_only {
if KNOWN_BD_ONLY_COLUMNS.contains(&col.as_str()) {
info!(
"Known schema difference: column '{}' in bd but not br (Gastown feature)",
col
);
}
}
assert!(
unexpected_bd_cols.is_empty(),
"bd issues table has unexpected columns not in br (not in known list): {:?}\nKnown differences: {:?}",
unexpected_bd_cols,
KNOWN_BD_ONLY_COLUMNS
);
}
#[test]
fn conformance_schema_dependencies_structure() {
skip_if_no_binaries!();
common::init_test_logging();
info!("Starting conformance_schema_dependencies_structure test");
let workspace = SchemaWorkspace::new();
workspace.init_both();
let br_db = workspace.br_db_path();
let bd_db = workspace.bd_db_path();
let br_cols = get_table_columns(&workspace, &br_db, "dependencies");
let bd_cols = get_table_columns(&workspace, &bd_db, "dependencies");
let required = ["issue_id", "depends_on_id", "type", "created_at"];
let br_names: HashSet<String> = br_cols.iter().map(|c| c.name.clone()).collect();
let bd_names: HashSet<String> = bd_cols.iter().map(|c| c.name.clone()).collect();
for col in required {
assert!(
br_names.contains(col),
"Column '{}' missing in br dependencies table",
col
);
assert!(
bd_names.contains(col),
"Column '{}' missing in bd dependencies table",
col
);
}
}
#[test]
fn conformance_schema_labels_comments() {
skip_if_no_binaries!();
common::init_test_logging();
info!("Starting conformance_schema_labels_comments test");
let workspace = SchemaWorkspace::new();
workspace.init_both();
let br_db = workspace.br_db_path();
let bd_db = workspace.bd_db_path();
let br_labels = get_table_columns(&workspace, &br_db, "labels");
let bd_labels = get_table_columns(&workspace, &bd_db, "labels");
let br_label_names: HashSet<String> = br_labels.iter().map(|c| c.name.clone()).collect();
let bd_label_names: HashSet<String> = bd_labels.iter().map(|c| c.name.clone()).collect();
assert!(
br_label_names.contains("issue_id"),
"labels.issue_id missing in br"
);
assert!(
br_label_names.contains("label"),
"labels.label missing in br"
);
assert!(
bd_label_names.contains("issue_id"),
"labels.issue_id missing in bd"
);
assert!(
bd_label_names.contains("label"),
"labels.label missing in bd"
);
let br_comments = get_table_columns(&workspace, &br_db, "comments");
let bd_comments = get_table_columns(&workspace, &bd_db, "comments");
let br_comment_names: HashSet<String> = br_comments.iter().map(|c| c.name.clone()).collect();
let bd_comment_names: HashSet<String> = bd_comments.iter().map(|c| c.name.clone()).collect();
let comment_required = ["id", "issue_id", "author", "text", "created_at"];
for col in comment_required {
assert!(
br_comment_names.contains(col),
"comments.{} missing in br",
col
);
assert!(
bd_comment_names.contains(col),
"comments.{} missing in bd",
col
);
}
}
#[test]
fn conformance_schema_indexes() {
skip_if_no_binaries!();
common::init_test_logging();
info!("Starting conformance_schema_indexes test");
let workspace = SchemaWorkspace::new();
workspace.init_both();
let comparison = compare_schemas(&workspace);
for diff in &comparison.index_diffs {
info!(
"Index diff in {}: {} - {}",
diff.table, diff.index, diff.diff_type
);
}
let br_db = workspace.br_db_path();
let bd_db = workspace.bd_db_path();
let br_issues_idx = get_table_indexes(&workspace, &br_db, "issues");
let bd_issues_idx = get_table_indexes(&workspace, &bd_db, "issues");
let br_has_status_idx = br_issues_idx
.iter()
.any(|i| i.columns.contains(&"status".to_string()));
let bd_has_status_idx = bd_issues_idx
.iter()
.any(|i| i.columns.contains(&"status".to_string()));
assert!(
br_has_status_idx,
"br should have an index on issues.status"
);
assert!(
bd_has_status_idx,
"bd should have an index on issues.status"
);
}
#[test]
fn conformance_metadata_json_structure() {
skip_if_no_binaries!();
common::init_test_logging();
info!("Starting conformance_metadata_json_structure test");
let workspace = SchemaWorkspace::new();
workspace.init_both();
let result = compare_metadata(&workspace);
assert!(
result.is_ok(),
"metadata.json comparison failed: {:?}",
result.err()
);
}
#[test]
fn conformance_jsonl_field_parity() {
skip_if_no_binaries!();
common::init_test_logging();
info!("Starting conformance_jsonl_field_parity test");
let workspace = SchemaWorkspace::new();
workspace.init_both();
let br_create = workspace.run_br(&["create", "Test issue for JSONL parity", "--json"]);
assert!(br_create.success, "br create failed: {}", br_create.stderr);
let bd_create = workspace.run_bd(&["create", "Test issue for JSONL parity", "--json"]);
assert!(bd_create.success, "bd create failed: {}", bd_create.stderr);
let _ = workspace.run_br(&["sync", "--flush-only"]);
let _ = workspace.run_bd(&["sync", "--flush-only"]);
let result = compare_jsonl_fields(&workspace);
assert!(
result.is_ok(),
"JSONL field parity check failed: {:?}",
result.err()
);
}
#[test]
fn conformance_jsonl_compaction_level_serialization() {
skip_if_no_binaries!();
common::init_test_logging();
info!("Starting conformance_jsonl_compaction_level_serialization test");
let workspace = SchemaWorkspace::new();
workspace.init_both();
let br_create = workspace.run_br(&["create", "Test compaction", "--json"]);
assert!(br_create.success, "br create failed: {}", br_create.stderr);
let br_json: Value = serde_json::from_str(&br_create.stdout).unwrap_or_default();
let br_id = br_json
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let _ = workspace.run_br(&["close", br_id, "--reason", "Testing"]);
let _ = workspace.run_br(&["sync", "--flush-only"]);
let br_jsonl = workspace.br_jsonl_path();
let content = fs::read_to_string(&br_jsonl).expect("read br jsonl");
for line in content.lines() {
if let Ok(issue) = serde_json::from_str::<Value>(line) {
if let Some(level) = issue.get("compaction_level") {
assert!(
level.is_number(),
"compaction_level should be a number, got: {:?}",
level
);
}
}
}
}
const KNOWN_OTHER_TABLE_DIFFS: &[(&str, &str, &str)] = &[
("blocked_issues_cache", "blocked_at", "missing_in_bd"),
("blocked_issues_cache", "blocked_by", "missing_in_bd"),
("blocked_issues_cache", "blocked_by_json", "missing_in_br"),
("child_counters", "last_child", "missing_in_bd"),
("child_counters", "next_child_number", "missing_in_br"),
("comments", "created_at", "type_mismatch"),
("dependencies", "created_at", "type_mismatch"),
("dirty_issues", "marked_at", "type_mismatch"),
("events", "created_at", "type_mismatch"),
("export_hashes", "exported_at", "type_mismatch"),
("dependencies", "created_by", "notnull_mismatch"),
];
fn is_known_column_diff(diff: &ColumnDiff) -> bool {
if diff.table == "issues" {
return match diff.diff_type.as_str() {
"missing_in_br" => KNOWN_BD_ONLY_COLUMNS.contains(&diff.column.as_str()),
"missing_in_bd" => KNOWN_BR_ONLY_COLUMNS.contains(&diff.column.as_str()),
"type_mismatch" => KNOWN_TYPE_DIFFERENCES.contains(&diff.column.as_str()),
"notnull_mismatch" => KNOWN_NOTNULL_DIFFERENCES.contains(&diff.column.as_str()),
_ => false,
};
}
KNOWN_OTHER_TABLE_DIFFS
.iter()
.any(|(table, col, diff_type)| {
diff.table == *table && diff.column == *col && diff.diff_type == *diff_type
})
}
#[test]
fn conformance_schema_full_comparison() {
skip_if_no_binaries!();
common::init_test_logging();
info!("Starting conformance_schema_full_comparison test");
let workspace = SchemaWorkspace::new();
workspace.init_both();
let comparison = compare_schemas(&workspace);
println!("Schema Comparison Summary:");
println!(" Common tables: {:?}", comparison.tables_common);
println!(" Tables in br only: {:?}", comparison.tables_br_only);
println!(" Tables in bd only: {:?}", comparison.tables_bd_only);
println!(" Column diffs: {}", comparison.column_diffs.len());
for diff in &comparison.column_diffs {
let known = if is_known_column_diff(diff) {
" [KNOWN]"
} else {
""
};
println!(
" {}.{}: {} (br: {}, bd: {}){}",
diff.table, diff.column, diff.diff_type, diff.br_value, diff.bd_value, known
);
}
println!(" Index diffs: {}", comparison.index_diffs.len());
let unexpected_table_diffs: Vec<&String> = comparison
.tables_bd_only
.iter()
.filter(|t| !KNOWN_BD_ONLY_TABLES.contains(&t.as_str()))
.collect();
let unexpected_column_diffs: Vec<&ColumnDiff> = comparison
.column_diffs
.iter()
.filter(|d| !is_known_column_diff(d))
.collect();
let known_table_count = comparison.tables_bd_only.len() - unexpected_table_diffs.len();
let known_column_count = comparison.column_diffs.len() - unexpected_column_diffs.len();
if known_table_count > 0 {
info!(
"{} known table differences (Gastown features)",
known_table_count
);
}
if known_column_count > 0 {
info!(
"{} known column differences (Gastown features, type/notnull)",
known_column_count
);
}
assert!(
unexpected_table_diffs.is_empty(),
"Unexpected tables in bd not in br: {:?}",
unexpected_table_diffs
);
assert!(
unexpected_column_diffs.is_empty(),
"Unexpected column differences:\n{}",
unexpected_column_diffs
.iter()
.map(|d| format!(
" {}.{}: {} (br: {}, bd: {})",
d.table, d.column, d.diff_type, d.br_value, d.bd_value
))
.collect::<Vec<_>>()
.join("\n")
);
if !comparison.index_diffs.is_empty() {
info!(
"{} index differences (informational, names auto-generated)",
comparison.index_diffs.len()
);
}
println!("\n[PASS] Schema conformance check passed with known differences documented");
}