use std::path::PathBuf;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Decision {
pub id: String,
pub module: PathBuf,
pub target: Option<String>,
pub decision_type: DecisionType,
pub choice: String,
pub alternatives: Vec<String>,
pub reasoning: Option<String>,
pub model_id: String,
pub confidence: f64,
pub pinned: bool,
pub pin_reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pinned_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pinned_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub superseded_by: Option<String>,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DecisionType {
Codegen,
Repair,
Optimize,
RuleApplied,
HandlerChoice,
AdaptiveRecovery,
}
impl DecisionType {
#[must_use]
pub fn scope(&self) -> ManifestScope {
match self {
Self::AdaptiveRecovery => ManifestScope::Runtime,
Self::Codegen
| Self::Repair
| Self::Optimize
| Self::RuleApplied
| Self::HandlerChoice => ManifestScope::Build,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ManifestScope {
Build,
Runtime,
}
impl ManifestScope {
#[must_use]
pub fn dir_name(self) -> &'static str {
match self {
Self::Build => "build",
Self::Runtime => "runtime",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn codegen_routes_to_build() {
assert_eq!(DecisionType::Codegen.scope(), ManifestScope::Build);
assert_eq!(DecisionType::Repair.scope(), ManifestScope::Build);
assert_eq!(DecisionType::Optimize.scope(), ManifestScope::Build);
assert_eq!(DecisionType::RuleApplied.scope(), ManifestScope::Build);
assert_eq!(DecisionType::HandlerChoice.scope(), ManifestScope::Build);
}
#[test]
fn adaptive_recovery_routes_to_runtime() {
assert_eq!(
DecisionType::AdaptiveRecovery.scope(),
ManifestScope::Runtime
);
}
#[test]
fn manifest_scope_dir_names() {
assert_eq!(ManifestScope::Build.dir_name(), "build");
assert_eq!(ManifestScope::Runtime.dir_name(), "runtime");
}
#[test]
fn decision_round_trips_through_json() {
let d = Decision {
id: "abc123".into(),
module: PathBuf::from("src/lib.bock"),
target: Some("rust".into()),
decision_type: DecisionType::Codegen,
choice: "tokio".into(),
alternatives: vec!["async-std".into(), "smol".into()],
reasoning: Some("axum requires tokio".into()),
model_id: "anthropic:claude-opus".into(),
confidence: 0.92,
pinned: false,
pin_reason: None,
pinned_at: None,
pinned_by: None,
superseded_by: None,
timestamp: DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap(),
};
let s = serde_json::to_string(&d).expect("serialize");
let d2: Decision = serde_json::from_str(&s).expect("deserialize");
assert_eq!(d, d2);
}
#[test]
fn pin_metadata_round_trips() {
let d = Decision {
id: "abc123".into(),
module: PathBuf::from("src/lib.bock"),
target: Some("rust".into()),
decision_type: DecisionType::Codegen,
choice: "tokio".into(),
alternatives: vec![],
reasoning: None,
model_id: "anthropic:claude-opus".into(),
confidence: 0.92,
pinned: true,
pin_reason: Some("reviewed by @alice 2026-04-22".into()),
pinned_at: Some(DateTime::<Utc>::from_timestamp(1_745_000_000, 0).unwrap()),
pinned_by: Some("alice".into()),
superseded_by: None,
timestamp: DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap(),
};
let s = serde_json::to_string(&d).expect("serialize");
let d2: Decision = serde_json::from_str(&s).expect("deserialize");
assert_eq!(d, d2);
}
#[test]
fn missing_optional_fields_deserialize_as_none() {
let json = r#"{
"id": "x",
"module": "src/lib.bock",
"target": "rust",
"decision_type": "codegen",
"choice": "tokio",
"alternatives": [],
"reasoning": null,
"model_id": "stub:stub",
"confidence": 1.0,
"pinned": false,
"pin_reason": null,
"timestamp": "2026-04-22T10:00:00Z"
}"#;
let d: Decision = serde_json::from_str(json).expect("backward-compatible parse");
assert!(d.pinned_at.is_none());
assert!(d.pinned_by.is_none());
assert!(d.superseded_by.is_none());
}
}