ricecoder_specs/
change_tracking.rs

1//! Change tracking for spec modifications
2
3use crate::models::{ChangeDetail, Spec, SpecChange};
4use chrono::Utc;
5use std::collections::HashMap;
6use std::sync::{Arc, Mutex};
7
8/// Tracks spec evolution and modifications
9pub struct ChangeTracker {
10    /// In-memory storage of change history by spec_id
11    history: Arc<Mutex<HashMap<String, Vec<SpecChange>>>>,
12    /// Counter for generating unique change IDs
13    change_counter: Arc<Mutex<u64>>,
14}
15
16impl ChangeTracker {
17    /// Create a new ChangeTracker
18    pub fn new() -> Self {
19        Self {
20            history: Arc::new(Mutex::new(HashMap::new())),
21            change_counter: Arc::new(Mutex::new(0)),
22        }
23    }
24
25    /// Record a spec change with field-level tracking
26    pub fn record_change(
27        &self,
28        spec_id: &str,
29        old: &Spec,
30        new: &Spec,
31        author: Option<String>,
32        rationale: String,
33    ) -> SpecChange {
34        // Generate unique change ID
35        let mut counter = self.change_counter.lock().unwrap();
36        *counter += 1;
37        let change_id = format!("change-{}", counter);
38        drop(counter);
39
40        // Detect field-level changes
41        let changes = Self::detect_changes(old, new);
42
43        let spec_change = SpecChange {
44            id: change_id,
45            spec_id: spec_id.to_string(),
46            timestamp: Utc::now(),
47            author,
48            rationale,
49            changes,
50        };
51
52        // Store in history
53        let mut history = self.history.lock().unwrap();
54        history
55            .entry(spec_id.to_string())
56            .or_default()
57            .push(spec_change.clone());
58
59        spec_change
60    }
61
62    /// Get change history for a spec
63    pub fn get_history(&self, spec_id: &str) -> Vec<SpecChange> {
64        let history = self.history.lock().unwrap();
65        history.get(spec_id).cloned().unwrap_or_default()
66    }
67
68    /// Get all changes across all specs
69    pub fn get_all_changes(&self) -> Vec<SpecChange> {
70        let history = self.history.lock().unwrap();
71        history
72            .values()
73            .flat_map(|changes| changes.clone())
74            .collect()
75    }
76
77    /// Clear history for a spec (useful for testing)
78    pub fn clear_history(&self, spec_id: &str) {
79        let mut history = self.history.lock().unwrap();
80        history.remove(spec_id);
81    }
82
83    /// Detect field-level changes between two specs
84    fn detect_changes(old: &Spec, new: &Spec) -> Vec<ChangeDetail> {
85        let mut changes = Vec::new();
86
87        // Check name
88        if old.name != new.name {
89            changes.push(ChangeDetail {
90                field: "name".to_string(),
91                old_value: Some(old.name.clone()),
92                new_value: Some(new.name.clone()),
93            });
94        }
95
96        // Check version
97        if old.version != new.version {
98            changes.push(ChangeDetail {
99                field: "version".to_string(),
100                old_value: Some(old.version.clone()),
101                new_value: Some(new.version.clone()),
102            });
103        }
104
105        // Check metadata phase
106        if old.metadata.phase != new.metadata.phase {
107            changes.push(ChangeDetail {
108                field: "metadata.phase".to_string(),
109                old_value: Some(format!("{:?}", old.metadata.phase)),
110                new_value: Some(format!("{:?}", new.metadata.phase)),
111            });
112        }
113
114        // Check metadata status
115        if old.metadata.status != new.metadata.status {
116            changes.push(ChangeDetail {
117                field: "metadata.status".to_string(),
118                old_value: Some(format!("{:?}", old.metadata.status)),
119                new_value: Some(format!("{:?}", new.metadata.status)),
120            });
121        }
122
123        // Check metadata author
124        if old.metadata.author != new.metadata.author {
125            changes.push(ChangeDetail {
126                field: "metadata.author".to_string(),
127                old_value: old.metadata.author.clone(),
128                new_value: new.metadata.author.clone(),
129            });
130        }
131
132        // Check requirements count
133        if old.requirements.len() != new.requirements.len() {
134            changes.push(ChangeDetail {
135                field: "requirements.count".to_string(),
136                old_value: Some(old.requirements.len().to_string()),
137                new_value: Some(new.requirements.len().to_string()),
138            });
139        }
140
141        // Check design presence
142        let old_has_design = old.design.is_some();
143        let new_has_design = new.design.is_some();
144        if old_has_design != new_has_design {
145            changes.push(ChangeDetail {
146                field: "design".to_string(),
147                old_value: Some(old_has_design.to_string()),
148                new_value: Some(new_has_design.to_string()),
149            });
150        }
151
152        // Check tasks count
153        if old.tasks.len() != new.tasks.len() {
154            changes.push(ChangeDetail {
155                field: "tasks.count".to_string(),
156                old_value: Some(old.tasks.len().to_string()),
157                new_value: Some(new.tasks.len().to_string()),
158            });
159        }
160
161        changes
162    }
163}
164
165impl Default for ChangeTracker {
166    fn default() -> Self {
167        Self::new()
168    }
169}
170
171impl Clone for ChangeTracker {
172    fn clone(&self) -> Self {
173        Self {
174            history: Arc::clone(&self.history),
175            change_counter: Arc::clone(&self.change_counter),
176        }
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use crate::models::{SpecMetadata, SpecPhase, SpecStatus};
184
185    #[test]
186    fn test_change_tracker_creation() {
187        let tracker = ChangeTracker::new();
188        let history = tracker.get_history("test-spec");
189        assert_eq!(history.len(), 0);
190    }
191
192    #[test]
193    fn test_record_change_with_name_change() {
194        let tracker = ChangeTracker::new();
195        let now = Utc::now();
196
197        let old_spec = Spec {
198            id: "spec-1".to_string(),
199            name: "Old Name".to_string(),
200            version: "1.0.0".to_string(),
201            requirements: vec![],
202            design: None,
203            tasks: vec![],
204            metadata: SpecMetadata {
205                author: Some("Author".to_string()),
206                created_at: now,
207                updated_at: now,
208                phase: SpecPhase::Requirements,
209                status: SpecStatus::Draft,
210            },
211            inheritance: None,
212        };
213
214        let mut new_spec = old_spec.clone();
215        new_spec.name = "New Name".to_string();
216
217        let change = tracker.record_change(
218            "spec-1",
219            &old_spec,
220            &new_spec,
221            Some("John Doe".to_string()),
222            "Updated spec name".to_string(),
223        );
224
225        assert_eq!(change.spec_id, "spec-1");
226        assert_eq!(change.author, Some("John Doe".to_string()));
227        assert_eq!(change.rationale, "Updated spec name");
228        assert!(!change.changes.is_empty());
229
230        let name_change = change.changes.iter().find(|c| c.field == "name").unwrap();
231        assert_eq!(name_change.old_value, Some("Old Name".to_string()));
232        assert_eq!(name_change.new_value, Some("New Name".to_string()));
233    }
234
235    #[test]
236    fn test_record_change_with_version_change() {
237        let tracker = ChangeTracker::new();
238        let now = Utc::now();
239
240        let old_spec = Spec {
241            id: "spec-1".to_string(),
242            name: "Test".to_string(),
243            version: "1.0.0".to_string(),
244            requirements: vec![],
245            design: None,
246            tasks: vec![],
247            metadata: SpecMetadata {
248                author: None,
249                created_at: now,
250                updated_at: now,
251                phase: SpecPhase::Requirements,
252                status: SpecStatus::Draft,
253            },
254            inheritance: None,
255        };
256
257        let mut new_spec = old_spec.clone();
258        new_spec.version = "1.1.0".to_string();
259
260        let change = tracker.record_change(
261            "spec-1",
262            &old_spec,
263            &new_spec,
264            None,
265            "Version bump".to_string(),
266        );
267
268        let version_change = change
269            .changes
270            .iter()
271            .find(|c| c.field == "version")
272            .unwrap();
273        assert_eq!(version_change.old_value, Some("1.0.0".to_string()));
274        assert_eq!(version_change.new_value, Some("1.1.0".to_string()));
275    }
276
277    #[test]
278    fn test_get_history_preserves_order() {
279        let tracker = ChangeTracker::new();
280        let now = Utc::now();
281
282        let spec = Spec {
283            id: "spec-1".to_string(),
284            name: "Test".to_string(),
285            version: "1.0.0".to_string(),
286            requirements: vec![],
287            design: None,
288            tasks: vec![],
289            metadata: SpecMetadata {
290                author: None,
291                created_at: now,
292                updated_at: now,
293                phase: SpecPhase::Requirements,
294                status: SpecStatus::Draft,
295            },
296            inheritance: None,
297        };
298
299        // Record multiple changes
300        let mut current = spec.clone();
301        for i in 0..3 {
302            let mut next = current.clone();
303            next.version = format!("1.{}.0", i + 1);
304            tracker.record_change("spec-1", &current, &next, None, format!("Change {}", i + 1));
305            current = next;
306        }
307
308        let history = tracker.get_history("spec-1");
309        assert_eq!(history.len(), 3);
310
311        // Verify order is preserved
312        for (i, change) in history.iter().enumerate() {
313            assert_eq!(change.rationale, format!("Change {}", i + 1));
314        }
315    }
316
317    #[test]
318    fn test_change_tracker_with_multiple_specs() {
319        let tracker = ChangeTracker::new();
320        let now = Utc::now();
321
322        let spec1 = Spec {
323            id: "spec-1".to_string(),
324            name: "Spec 1".to_string(),
325            version: "1.0.0".to_string(),
326            requirements: vec![],
327            design: None,
328            tasks: vec![],
329            metadata: SpecMetadata {
330                author: None,
331                created_at: now,
332                updated_at: now,
333                phase: SpecPhase::Requirements,
334                status: SpecStatus::Draft,
335            },
336            inheritance: None,
337        };
338
339        let spec2 = Spec {
340            id: "spec-2".to_string(),
341            name: "Spec 2".to_string(),
342            version: "1.0.0".to_string(),
343            requirements: vec![],
344            design: None,
345            tasks: vec![],
346            metadata: SpecMetadata {
347                author: None,
348                created_at: now,
349                updated_at: now,
350                phase: SpecPhase::Design,
351                status: SpecStatus::Draft,
352            },
353            inheritance: None,
354        };
355
356        let mut spec1_v2 = spec1.clone();
357        spec1_v2.version = "1.1.0".to_string();
358
359        let mut spec2_v2 = spec2.clone();
360        spec2_v2.version = "2.0.0".to_string();
361
362        tracker.record_change("spec-1", &spec1, &spec1_v2, None, "Update 1".to_string());
363        tracker.record_change("spec-2", &spec2, &spec2_v2, None, "Update 2".to_string());
364
365        assert_eq!(tracker.get_history("spec-1").len(), 1);
366        assert_eq!(tracker.get_history("spec-2").len(), 1);
367        assert_eq!(tracker.get_all_changes().len(), 2);
368    }
369
370    #[test]
371    fn test_change_detail_with_no_changes() {
372        let tracker = ChangeTracker::new();
373        let now = Utc::now();
374
375        let spec = Spec {
376            id: "spec-1".to_string(),
377            name: "Test".to_string(),
378            version: "1.0.0".to_string(),
379            requirements: vec![],
380            design: None,
381            tasks: vec![],
382            metadata: SpecMetadata {
383                author: None,
384                created_at: now,
385                updated_at: now,
386                phase: SpecPhase::Requirements,
387                status: SpecStatus::Draft,
388            },
389            inheritance: None,
390        };
391
392        let change = tracker.record_change(
393            "spec-1",
394            &spec,
395            &spec,
396            None,
397            "No actual changes".to_string(),
398        );
399
400        assert_eq!(change.changes.len(), 0);
401    }
402
403    #[test]
404    fn test_change_timestamps_are_recent() {
405        let tracker = ChangeTracker::new();
406        let now = Utc::now();
407
408        let spec = Spec {
409            id: "spec-1".to_string(),
410            name: "Test".to_string(),
411            version: "1.0.0".to_string(),
412            requirements: vec![],
413            design: None,
414            tasks: vec![],
415            metadata: SpecMetadata {
416                author: None,
417                created_at: now,
418                updated_at: now,
419                phase: SpecPhase::Requirements,
420                status: SpecStatus::Draft,
421            },
422            inheritance: None,
423        };
424
425        let mut new_spec = spec.clone();
426        new_spec.version = "1.1.0".to_string();
427
428        let change = tracker.record_change("spec-1", &spec, &new_spec, None, "Update".to_string());
429
430        // Timestamp should be very recent (within last second)
431        let time_diff = Utc::now().signed_duration_since(change.timestamp);
432        assert!(time_diff.num_seconds() < 1);
433    }
434
435    #[test]
436    fn test_clear_history() {
437        let tracker = ChangeTracker::new();
438        let now = Utc::now();
439
440        let spec = Spec {
441            id: "spec-1".to_string(),
442            name: "Test".to_string(),
443            version: "1.0.0".to_string(),
444            requirements: vec![],
445            design: None,
446            tasks: vec![],
447            metadata: SpecMetadata {
448                author: None,
449                created_at: now,
450                updated_at: now,
451                phase: SpecPhase::Requirements,
452                status: SpecStatus::Draft,
453            },
454            inheritance: None,
455        };
456
457        let mut new_spec = spec.clone();
458        new_spec.version = "1.1.0".to_string();
459
460        tracker.record_change("spec-1", &spec, &new_spec, None, "Update".to_string());
461        assert_eq!(tracker.get_history("spec-1").len(), 1);
462
463        tracker.clear_history("spec-1");
464        assert_eq!(tracker.get_history("spec-1").len(), 0);
465    }
466
467    #[test]
468    fn test_change_tracker_clone() {
469        let tracker = ChangeTracker::new();
470        let now = Utc::now();
471
472        let spec = Spec {
473            id: "spec-1".to_string(),
474            name: "Test".to_string(),
475            version: "1.0.0".to_string(),
476            requirements: vec![],
477            design: None,
478            tasks: vec![],
479            metadata: SpecMetadata {
480                author: None,
481                created_at: now,
482                updated_at: now,
483                phase: SpecPhase::Requirements,
484                status: SpecStatus::Draft,
485            },
486            inheritance: None,
487        };
488
489        let mut new_spec = spec.clone();
490        new_spec.version = "1.1.0".to_string();
491
492        tracker.record_change("spec-1", &spec, &new_spec, None, "Update".to_string());
493
494        let cloned_tracker = tracker.clone();
495        assert_eq!(cloned_tracker.get_history("spec-1").len(), 1);
496    }
497}