use aimdb_codegen::{ArchitectureState, BufferType};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Conflict {
pub record_name: String,
pub conflict_type: ConflictType,
pub message: String,
pub severity: ConflictSeverity,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ConflictType {
MissingInInstance,
MissingInState,
BufferMismatch,
CapacityMismatch,
ConnectorMismatch,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ConflictSeverity {
Error,
Warning,
Info,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConflictReport {
pub conflicts: Vec<Conflict>,
pub error_count: usize,
pub warning_count: usize,
pub info_count: usize,
pub in_sync: bool,
}
impl ConflictReport {
pub fn from_conflicts(conflicts: Vec<Conflict>) -> Self {
let error_count = conflicts
.iter()
.filter(|c| c.severity == ConflictSeverity::Error)
.count();
let warning_count = conflicts
.iter()
.filter(|c| c.severity == ConflictSeverity::Warning)
.count();
let info_count = conflicts
.iter()
.filter(|c| c.severity == ConflictSeverity::Info)
.count();
let in_sync = error_count == 0 && warning_count == 0;
Self {
conflicts,
error_count,
warning_count,
info_count,
in_sync,
}
}
}
#[derive(Debug, Clone)]
pub struct LiveRecord {
pub name: String,
pub buffer_type: String,
pub buffer_capacity: Option<usize>,
}
fn state_buffer_to_wire(bt: &BufferType) -> &'static str {
match bt {
BufferType::SpmcRing => "spmc_ring",
BufferType::SingleLatest => "single_latest",
BufferType::Mailbox => "mailbox",
}
}
pub fn detect_conflicts(state: &ArchitectureState, live_records: &[LiveRecord]) -> ConflictReport {
let mut conflicts: Vec<Conflict> = Vec::new();
for rec in &state.records {
let matching_live: Vec<&LiveRecord> = live_records
.iter()
.filter(|lr| {
if rec.key_prefix.is_empty() {
rec.key_variants.contains(&lr.name) || lr.name == rec.name
} else {
lr.name.starts_with(&rec.key_prefix)
}
})
.collect();
if matching_live.is_empty() {
conflicts.push(Conflict {
record_name: rec.name.clone(),
conflict_type: ConflictType::MissingInInstance,
message: format!(
"'{}' is declared in state.toml but not found in the running instance. \
Codegen may not have run yet, or the binary has not been redeployed.",
rec.name
),
severity: ConflictSeverity::Warning,
});
continue;
}
let expected_buffer_wire = state_buffer_to_wire(&rec.buffer);
for lr in &matching_live {
if lr.buffer_type != expected_buffer_wire && lr.buffer_type != "none" {
conflicts.push(Conflict {
record_name: lr.name.clone(),
conflict_type: ConflictType::BufferMismatch,
message: format!(
"'{}': state.toml expects buffer '{}' but instance reports '{}'. \
Likely a stale build — rerun `aimdb generate && cargo build`.",
lr.name, expected_buffer_wire, lr.buffer_type
),
severity: ConflictSeverity::Error,
});
continue;
}
if rec.buffer == BufferType::SpmcRing {
if let (Some(expected), Some(actual)) = (rec.capacity, lr.buffer_capacity) {
if expected != actual {
conflicts.push(Conflict {
record_name: lr.name.clone(),
conflict_type: ConflictType::CapacityMismatch,
message: format!(
"'{}': state.toml declares SpmcRing capacity {} but \
instance reports capacity {}. \
May be an intentional application-level override.",
lr.name, expected, actual
),
severity: ConflictSeverity::Warning,
});
}
}
}
}
}
for lr in live_records {
let present_in_state = state.records.iter().any(|rec| {
if rec.key_prefix.is_empty() {
rec.key_variants.contains(&lr.name) || lr.name == rec.name
} else {
lr.name.starts_with(&rec.key_prefix)
}
});
if !present_in_state {
conflicts.push(Conflict {
record_name: lr.name.clone(),
conflict_type: ConflictType::MissingInState,
message: format!(
"'{}' exists in the running instance but is not declared in state.toml. \
It may be a manually registered record not managed by the architecture agent.",
lr.name
),
severity: ConflictSeverity::Info,
});
}
}
ConflictReport::from_conflicts(conflicts)
}
#[cfg(test)]
mod tests {
use super::*;
use aimdb_codegen::ArchitectureState;
const STATE_TOML: &str = r#"
[meta]
aimdb_version = "0.5.0"
created_at = "2026-02-22T14:00:00Z"
last_modified = "2026-02-22T14:00:00Z"
[[records]]
name = "TemperatureReading"
buffer = "SpmcRing"
capacity = 256
key_prefix = "sensors.temp."
key_variants = ["indoor", "outdoor"]
producers = ["sensor_task"]
consumers = ["dashboard"]
[[records.fields]]
name = "celsius"
type = "f64"
description = "Celsius"
"#;
fn state() -> ArchitectureState {
ArchitectureState::from_toml(STATE_TOML).unwrap()
}
#[test]
fn in_sync_when_buffer_matches() {
let live = vec![
LiveRecord {
name: "sensors.temp.indoor".to_string(),
buffer_type: "spmc_ring".to_string(),
buffer_capacity: Some(256),
},
LiveRecord {
name: "sensors.temp.outdoor".to_string(),
buffer_type: "spmc_ring".to_string(),
buffer_capacity: Some(256),
},
];
let report = detect_conflicts(&state(), &live);
assert!(report.in_sync, "Should be in sync: {:?}", report.conflicts);
}
#[test]
fn detects_missing_in_instance() {
let report = detect_conflicts(&state(), &[]);
assert_eq!(report.warning_count, 1);
assert!(report.conflicts[0].conflict_type == ConflictType::MissingInInstance);
}
#[test]
fn detects_buffer_mismatch() {
let live = vec![LiveRecord {
name: "sensors.temp.indoor".to_string(),
buffer_type: "single_latest".to_string(),
buffer_capacity: None,
}];
let report = detect_conflicts(&state(), &live);
let has_mismatch = report
.conflicts
.iter()
.any(|c| c.conflict_type == ConflictType::BufferMismatch);
assert!(
has_mismatch,
"Should detect buffer mismatch: {:?}",
report.conflicts
);
assert!(report.error_count > 0);
}
#[test]
fn detects_capacity_mismatch() {
let live = vec![LiveRecord {
name: "sensors.temp.indoor".to_string(),
buffer_type: "spmc_ring".to_string(),
buffer_capacity: Some(1024),
}];
let report = detect_conflicts(&state(), &live);
let has_cap = report
.conflicts
.iter()
.any(|c| c.conflict_type == ConflictType::CapacityMismatch);
assert!(
has_cap,
"Should detect capacity mismatch: {:?}",
report.conflicts
);
}
#[test]
fn detects_missing_in_state() {
let live = vec![
LiveRecord {
name: "sensors.temp.indoor".to_string(),
buffer_type: "spmc_ring".to_string(),
buffer_capacity: Some(256),
},
LiveRecord {
name: "some.other.record".to_string(),
buffer_type: "single_latest".to_string(),
buffer_capacity: None,
},
];
let report = detect_conflicts(&state(), &live);
let has_missing = report
.conflicts
.iter()
.any(|c| c.conflict_type == ConflictType::MissingInState);
assert!(
has_missing,
"Should detect missing_in_state: {:?}",
report.conflicts
);
assert_eq!(report.info_count, 1);
}
}