1use std::collections::HashMap;
8
9use super::event::{Actor, AuditEvent, AuditEventBuilder, EntityType, EventContext, ops};
10use super::materialize::EntityKey;
11
12pub type FileState = HashMap<EntityKey, String>;
15
16#[derive(Debug, Clone, PartialEq)]
18pub enum Drift {
19 Missing {
21 key: EntityKey,
23 file_status: String,
25 },
26 Diverged {
28 key: EntityKey,
30 log_status: String,
32 file_status: String,
34 },
35 Extra {
37 key: EntityKey,
39 log_status: String,
41 },
42}
43
44impl std::fmt::Display for Drift {
45 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46 match self {
47 Drift::Missing { key, file_status } => write!(
48 f,
49 "Missing: {}/{} (scope: {:?}) has file status '{}' but no audit events",
50 key.entity, key.entity_id, key.scope, file_status
51 ),
52 Drift::Diverged {
53 key,
54 log_status,
55 file_status,
56 } => write!(
57 f,
58 "Diverged: {}/{} (scope: {:?}) audit='{}' file='{}'",
59 key.entity, key.entity_id, key.scope, log_status, file_status
60 ),
61 Drift::Extra { key, log_status } => write!(
62 f,
63 "Extra: {}/{} (scope: {:?}) has audit status '{}' but no file entry",
64 key.entity, key.entity_id, key.scope, log_status
65 ),
66 }
67 }
68}
69
70pub fn compute_drift(
74 audit_entities: &HashMap<EntityKey, String>,
75 file_state: &FileState,
76) -> Vec<Drift> {
77 let mut drifts = Vec::new();
78
79 for (key, file_status) in file_state {
81 match audit_entities.get(key) {
82 None => {
83 drifts.push(Drift::Missing {
84 key: key.clone(),
85 file_status: file_status.clone(),
86 });
87 }
88 Some(log_status) if log_status != file_status => {
89 drifts.push(Drift::Diverged {
90 key: key.clone(),
91 log_status: log_status.clone(),
92 file_status: file_status.clone(),
93 });
94 }
95 Some(_) => {
96 }
98 }
99 }
100
101 for (key, log_status) in audit_entities {
103 if key.entity == "task" && !file_state.contains_key(key) {
106 drifts.push(Drift::Extra {
107 key: key.clone(),
108 log_status: log_status.clone(),
109 });
110 }
111 }
112
113 drifts.sort_by(|a, b| {
115 let key_a = match a {
116 Drift::Missing { key, .. } => key,
117 Drift::Diverged { key, .. } => key,
118 Drift::Extra { key, .. } => key,
119 };
120 let key_b = match b {
121 Drift::Missing { key, .. } => key,
122 Drift::Diverged { key, .. } => key,
123 Drift::Extra { key, .. } => key,
124 };
125 (&key_a.entity, &key_a.entity_id).cmp(&(&key_b.entity, &key_b.entity_id))
126 });
127
128 drifts
129}
130
131pub fn generate_compensating_events(
135 drifts: &[Drift],
136 scope: Option<&str>,
137 ctx: &EventContext,
138) -> Vec<AuditEvent> {
139 let mut events = Vec::new();
140
141 for drift in drifts {
142 let event = match drift {
143 Drift::Missing { key, file_status } => AuditEventBuilder::new()
144 .entity(parse_entity_type(&key.entity))
145 .entity_id(&key.entity_id)
146 .op(ops::RECONCILED)
147 .to(file_status)
148 .actor(Actor::Reconcile)
149 .by("@reconcile")
150 .meta(serde_json::json!({
151 "reason": format!(
152 "{} '{}' has file status '{}' but no audit events",
153 key.entity, key.entity_id, file_status
154 )
155 }))
156 .ctx(ctx.clone()),
157 Drift::Diverged {
158 key,
159 log_status,
160 file_status,
161 } => AuditEventBuilder::new()
162 .entity(parse_entity_type(&key.entity))
163 .entity_id(&key.entity_id)
164 .op(ops::RECONCILED)
165 .from(log_status)
166 .to(file_status)
167 .actor(Actor::Reconcile)
168 .by("@reconcile")
169 .meta(serde_json::json!({
170 "reason": format!(
171 "{} '{}' audit status '{}' differs from file status '{}'",
172 key.entity, key.entity_id, log_status, file_status
173 )
174 }))
175 .ctx(ctx.clone()),
176 Drift::Extra { key, log_status } => AuditEventBuilder::new()
177 .entity(parse_entity_type(&key.entity))
178 .entity_id(&key.entity_id)
179 .op(ops::RECONCILED)
180 .from(log_status)
181 .actor(Actor::Reconcile)
182 .by("@reconcile")
183 .meta(serde_json::json!({
184 "reason": format!(
185 "{} '{}' has audit status '{}' but no file entry",
186 key.entity, key.entity_id, log_status
187 )
188 }))
189 .ctx(ctx.clone()),
190 };
191
192 let event = if let Some(s) = scope {
194 event.scope(s)
195 } else if let Some(s) = match drift {
196 Drift::Missing { key, .. } => key.scope.as_deref(),
197 Drift::Diverged { key, .. } => key.scope.as_deref(),
198 Drift::Extra { key, .. } => key.scope.as_deref(),
199 } {
200 event.scope(s)
201 } else {
202 event
203 };
204
205 if let Some(built) = event.build() {
206 events.push(built);
207 }
208 }
209
210 events
211}
212
213fn parse_entity_type(s: &str) -> EntityType {
215 match s {
216 "task" => EntityType::Task,
217 "change" => EntityType::Change,
218 "module" => EntityType::Module,
219 "wave" => EntityType::Wave,
220 "planning" => EntityType::Planning,
221 "config" => EntityType::Config,
222 _ => EntityType::Task,
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230 use crate::audit::materialize::EntityKey;
231
232 fn test_ctx() -> EventContext {
233 EventContext {
234 session_id: "test-session".to_string(),
235 harness_session_id: None,
236 branch: None,
237 worktree: None,
238 commit: None,
239 }
240 }
241
242 fn task_key(id: &str, scope: &str) -> EntityKey {
243 EntityKey {
244 entity: "task".to_string(),
245 entity_id: id.to_string(),
246 scope: Some(scope.to_string()),
247 }
248 }
249
250 #[test]
251 fn no_drift_when_states_match() {
252 let mut audit = HashMap::new();
253 audit.insert(task_key("1.1", "ch"), "complete".to_string());
254 audit.insert(task_key("1.2", "ch"), "pending".to_string());
255
256 let mut files = HashMap::new();
257 files.insert(task_key("1.1", "ch"), "complete".to_string());
258 files.insert(task_key("1.2", "ch"), "pending".to_string());
259
260 let drifts = compute_drift(&audit, &files);
261 assert!(drifts.is_empty());
262 }
263
264 #[test]
265 fn detect_missing_entity_in_log() {
266 let audit: HashMap<EntityKey, String> = HashMap::new();
267 let mut files = HashMap::new();
268 files.insert(task_key("1.1", "ch"), "complete".to_string());
269
270 let drifts = compute_drift(&audit, &files);
271 assert_eq!(drifts.len(), 1);
272 match &drifts[0] {
273 Drift::Missing { key, file_status } => {
274 assert_eq!(key.entity_id, "1.1");
275 assert_eq!(file_status, "complete");
276 }
277 other => panic!("Expected Missing, got {other:?}"),
278 }
279 }
280
281 #[test]
282 fn detect_diverged_status() {
283 let mut audit = HashMap::new();
284 audit.insert(task_key("1.1", "ch"), "pending".to_string());
285
286 let mut files = HashMap::new();
287 files.insert(task_key("1.1", "ch"), "complete".to_string());
288
289 let drifts = compute_drift(&audit, &files);
290 assert_eq!(drifts.len(), 1);
291 match &drifts[0] {
292 Drift::Diverged {
293 log_status,
294 file_status,
295 ..
296 } => {
297 assert_eq!(log_status, "pending");
298 assert_eq!(file_status, "complete");
299 }
300 other => panic!("Expected Diverged, got {other:?}"),
301 }
302 }
303
304 #[test]
305 fn detect_extra_in_log() {
306 let mut audit = HashMap::new();
307 audit.insert(task_key("1.1", "ch"), "in-progress".to_string());
308
309 let files: HashMap<EntityKey, String> = HashMap::new();
310
311 let drifts = compute_drift(&audit, &files);
312 assert_eq!(drifts.len(), 1);
313 match &drifts[0] {
314 Drift::Extra { key, log_status } => {
315 assert_eq!(key.entity_id, "1.1");
316 assert_eq!(log_status, "in-progress");
317 }
318 other => panic!("Expected Extra, got {other:?}"),
319 }
320 }
321
322 #[test]
323 fn multiple_drift_types_detected() {
324 let mut audit = HashMap::new();
325 audit.insert(task_key("1.1", "ch"), "pending".to_string()); audit.insert(task_key("1.3", "ch"), "complete".to_string()); let mut files = HashMap::new();
329 files.insert(task_key("1.1", "ch"), "complete".to_string()); files.insert(task_key("1.2", "ch"), "pending".to_string()); let drifts = compute_drift(&audit, &files);
333 assert_eq!(drifts.len(), 3);
334 }
335
336 #[test]
337 fn display_drift_items() {
338 let drift = Drift::Diverged {
339 key: task_key("1.1", "ch"),
340 log_status: "pending".to_string(),
341 file_status: "complete".to_string(),
342 };
343 let s = drift.to_string();
344 assert!(s.contains("Diverged"));
345 assert!(s.contains("1.1"));
346 assert!(s.contains("pending"));
347 assert!(s.contains("complete"));
348 }
349
350 #[test]
351 fn generate_compensating_events_for_missing() {
352 let drifts = vec![Drift::Missing {
353 key: task_key("1.1", "ch"),
354 file_status: "complete".to_string(),
355 }];
356
357 let events = generate_compensating_events(&drifts, Some("ch"), &test_ctx());
358 assert_eq!(events.len(), 1);
359 assert_eq!(events[0].op, "reconciled");
360 assert_eq!(events[0].actor, "reconcile");
361 assert_eq!(events[0].to, Some("complete".to_string()));
362 assert!(events[0].meta.is_some());
363 }
364
365 #[test]
366 fn generate_compensating_events_for_diverged() {
367 let drifts = vec![Drift::Diverged {
368 key: task_key("1.1", "ch"),
369 log_status: "pending".to_string(),
370 file_status: "complete".to_string(),
371 }];
372
373 let events = generate_compensating_events(&drifts, Some("ch"), &test_ctx());
374 assert_eq!(events.len(), 1);
375 assert_eq!(events[0].from, Some("pending".to_string()));
376 assert_eq!(events[0].to, Some("complete".to_string()));
377 }
378
379 #[test]
380 fn generate_compensating_events_for_extra() {
381 let drifts = vec![Drift::Extra {
382 key: task_key("1.1", "ch"),
383 log_status: "in-progress".to_string(),
384 }];
385
386 let events = generate_compensating_events(&drifts, Some("ch"), &test_ctx());
387 assert_eq!(events.len(), 1);
388 assert_eq!(events[0].from, Some("in-progress".to_string()));
389 assert!(events[0].to.is_none());
390 }
391
392 #[test]
393 fn compensating_events_use_scope_from_drift_key() {
394 let drifts = vec![Drift::Missing {
395 key: task_key("1.1", "my-change"),
396 file_status: "pending".to_string(),
397 }];
398
399 let events = generate_compensating_events(&drifts, None, &test_ctx());
401 assert_eq!(events.len(), 1);
402 assert_eq!(events[0].scope, Some("my-change".to_string()));
403 }
404}