1use std::path::PathBuf;
18
19use chrono::{DateTime, Utc};
20use serde::{Deserialize, Serialize};
21
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28pub struct Decision {
29 pub id: String,
31 pub module: PathBuf,
33 pub target: Option<String>,
36 pub decision_type: DecisionType,
39 pub choice: String,
42 pub alternatives: Vec<String>,
44 pub reasoning: Option<String>,
46 pub model_id: String,
48 pub confidence: f64,
50 pub pinned: bool,
52 pub pin_reason: Option<String>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub pinned_at: Option<DateTime<Utc>>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub pinned_by: Option<String>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub superseded_by: Option<String>,
65 pub timestamp: DateTime<Utc>,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum DecisionType {
77 Codegen,
80 Repair,
82 Optimize,
84 RuleApplied,
86
87 HandlerChoice,
92
93 AdaptiveRecovery,
96}
97
98impl DecisionType {
99 #[must_use]
104 pub fn scope(&self) -> ManifestScope {
105 match self {
106 Self::AdaptiveRecovery => ManifestScope::Runtime,
107 Self::Codegen
108 | Self::Repair
109 | Self::Optimize
110 | Self::RuleApplied
111 | Self::HandlerChoice => ManifestScope::Build,
112 }
113 }
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case")]
121pub enum ManifestScope {
122 Build,
124 Runtime,
126}
127
128impl ManifestScope {
129 #[must_use]
131 pub fn dir_name(self) -> &'static str {
132 match self {
133 Self::Build => "build",
134 Self::Runtime => "runtime",
135 }
136 }
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 #[test]
144 fn codegen_routes_to_build() {
145 assert_eq!(DecisionType::Codegen.scope(), ManifestScope::Build);
146 assert_eq!(DecisionType::Repair.scope(), ManifestScope::Build);
147 assert_eq!(DecisionType::Optimize.scope(), ManifestScope::Build);
148 assert_eq!(DecisionType::RuleApplied.scope(), ManifestScope::Build);
149 assert_eq!(DecisionType::HandlerChoice.scope(), ManifestScope::Build);
150 }
151
152 #[test]
153 fn adaptive_recovery_routes_to_runtime() {
154 assert_eq!(
155 DecisionType::AdaptiveRecovery.scope(),
156 ManifestScope::Runtime
157 );
158 }
159
160 #[test]
161 fn manifest_scope_dir_names() {
162 assert_eq!(ManifestScope::Build.dir_name(), "build");
163 assert_eq!(ManifestScope::Runtime.dir_name(), "runtime");
164 }
165
166 #[test]
167 fn decision_round_trips_through_json() {
168 let d = Decision {
169 id: "abc123".into(),
170 module: PathBuf::from("src/lib.bock"),
171 target: Some("rust".into()),
172 decision_type: DecisionType::Codegen,
173 choice: "tokio".into(),
174 alternatives: vec!["async-std".into(), "smol".into()],
175 reasoning: Some("axum requires tokio".into()),
176 model_id: "anthropic:claude-opus".into(),
177 confidence: 0.92,
178 pinned: false,
179 pin_reason: None,
180 pinned_at: None,
181 pinned_by: None,
182 superseded_by: None,
183 timestamp: DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap(),
184 };
185 let s = serde_json::to_string(&d).expect("serialize");
186 let d2: Decision = serde_json::from_str(&s).expect("deserialize");
187 assert_eq!(d, d2);
188 }
189
190 #[test]
191 fn pin_metadata_round_trips() {
192 let d = Decision {
193 id: "abc123".into(),
194 module: PathBuf::from("src/lib.bock"),
195 target: Some("rust".into()),
196 decision_type: DecisionType::Codegen,
197 choice: "tokio".into(),
198 alternatives: vec![],
199 reasoning: None,
200 model_id: "anthropic:claude-opus".into(),
201 confidence: 0.92,
202 pinned: true,
203 pin_reason: Some("reviewed by @alice 2026-04-22".into()),
204 pinned_at: Some(DateTime::<Utc>::from_timestamp(1_745_000_000, 0).unwrap()),
205 pinned_by: Some("alice".into()),
206 superseded_by: None,
207 timestamp: DateTime::<Utc>::from_timestamp(1_700_000_000, 0).unwrap(),
208 };
209 let s = serde_json::to_string(&d).expect("serialize");
210 let d2: Decision = serde_json::from_str(&s).expect("deserialize");
211 assert_eq!(d, d2);
212 }
213
214 #[test]
215 fn missing_optional_fields_deserialize_as_none() {
216 let json = r#"{
219 "id": "x",
220 "module": "src/lib.bock",
221 "target": "rust",
222 "decision_type": "codegen",
223 "choice": "tokio",
224 "alternatives": [],
225 "reasoning": null,
226 "model_id": "stub:stub",
227 "confidence": 1.0,
228 "pinned": false,
229 "pin_reason": null,
230 "timestamp": "2026-04-22T10:00:00Z"
231 }"#;
232 let d: Decision = serde_json::from_str(json).expect("backward-compatible parse");
233 assert!(d.pinned_at.is_none());
234 assert!(d.pinned_by.is_none());
235 assert!(d.superseded_by.is_none());
236 }
237}