Skip to main content

deepstrike_core/scheduler/
milestone.rs

1//! Milestone contract tracking extracted from LoopStateMachine.
2//!
3//! This module holds the milestone contract state (contract, current phase,
4//! blocked count) to reduce bloat in the main state machine. The complex
5//! handling logic remains in LoopStateMachine for now.
6
7use crate::types::milestone::MilestoneContract;
8
9/// Tracks milestone contract progress.
10///
11/// Extracted from `LoopStateMachine` to reduce state machine bloat.
12/// This struct only holds state; the actual milestone evaluation logic
13/// remains in `LoopStateMachine::handle_milestone_result`.
14pub struct MilestoneTracker {
15    /// Optional milestone contract loaded before the run starts.
16    pub(crate) contract: Option<MilestoneContract>,
17    /// Index of the current (not-yet-passed) phase within `contract`.
18    pub(crate) current_phase: usize,
19    /// How many times the current phase has been blocked (reset on advance).
20    pub(crate) blocked_count: usize,
21}
22
23impl MilestoneTracker {
24    /// Create a new milestone tracker with no contract loaded.
25    pub fn new() -> Self {
26        Self {
27            contract: None,
28            current_phase: 0,
29            blocked_count: 0,
30        }
31    }
32
33    /// Load a milestone contract. Must be called before the run starts.
34    pub fn load_contract(&mut self, contract: MilestoneContract) {
35        self.contract = Some(contract);
36        self.current_phase = 0;
37        self.blocked_count = 0;
38    }
39
40    /// Returns the ID of the current (not-yet-passed) phase, or `None` when
41    /// no contract is loaded or all phases are complete.
42    pub fn current_phase_id(&self) -> Option<&str> {
43        self.contract
44            .as_ref()
45            .and_then(|c| c.phases.get(self.current_phase))
46            .map(|p| p.id.as_str())
47    }
48
49    /// Returns the acceptance criteria of the current phase as a slice.
50    pub fn current_criteria(&self) -> &[String] {
51        self.contract
52            .as_ref()
53            .and_then(|c| c.phases.get(self.current_phase))
54            .map(|p| p.criteria.as_slice())
55            .unwrap_or(&[])
56    }
57
58    /// Returns `true` when there is no contract or all phases have passed.
59    pub fn is_complete(&self) -> bool {
60        match &self.contract {
61            None => true,
62            Some(c) => self.current_phase >= c.phases.len(),
63        }
64    }
65}
66
67impl Default for MilestoneTracker {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use crate::types::milestone::{MilestonePhase, MilestoneRollbackPolicy};
77
78    #[test]
79    fn test_tracker_no_contract_is_complete() {
80        let tracker = MilestoneTracker::new();
81        assert!(tracker.is_complete());
82        assert_eq!(tracker.current_phase_id(), None);
83        assert!(tracker.current_criteria().is_empty());
84    }
85
86    #[test]
87    fn test_tracker_single_phase_is_incomplete_until_passed() {
88        use crate::types::milestone::MilestoneUnlockPolicy;
89        let contract = MilestoneContract {
90            phases: vec![MilestonePhase {
91                id: "phase1".to_string(),
92                criteria: vec!["c1".to_string()],
93                unlocks: vec![],
94                retry_policy: None,
95                verifier: None,
96                required_evidence: vec![],
97                unlock_policy: MilestoneUnlockPolicy::Immediate,
98                rollback_policy: MilestoneRollbackPolicy::Terminate,
99            }],
100        };
101        let mut tracker = MilestoneTracker::new();
102        tracker.load_contract(contract);
103
104        assert!(!tracker.is_complete());
105        assert_eq!(tracker.current_phase_id(), Some("phase1"));
106        assert_eq!(tracker.current_criteria(), &["c1".to_string()]);
107    }
108
109    #[test]
110    fn test_tracker_multi_phase_advances_on_pass() {
111        use crate::types::milestone::MilestoneUnlockPolicy;
112        let contract = MilestoneContract {
113            phases: vec![
114                MilestonePhase {
115                    id: "phase1".to_string(),
116                    criteria: vec!["c1".to_string()],
117                    unlocks: vec![],
118                    retry_policy: None,
119                    verifier: None,
120                    required_evidence: vec![],
121                    unlock_policy: MilestoneUnlockPolicy::Immediate,
122                    rollback_policy: MilestoneRollbackPolicy::Terminate,
123                },
124                MilestonePhase {
125                    id: "phase2".to_string(),
126                    criteria: vec!["c2".to_string()],
127                    unlocks: vec![],
128                    retry_policy: None,
129                    verifier: None,
130                    required_evidence: vec![],
131                    unlock_policy: MilestoneUnlockPolicy::Immediate,
132                    rollback_policy: MilestoneRollbackPolicy::Terminate,
133                },
134            ],
135        };
136        let mut tracker = MilestoneTracker::new();
137        tracker.load_contract(contract);
138
139        assert_eq!(tracker.current_phase_id(), Some("phase1"));
140        tracker.current_phase += 1; // Simulate phase advance
141        assert_eq!(tracker.current_phase_id(), Some("phase2"));
142        tracker.current_phase += 1;
143        assert!(tracker.is_complete());
144        assert_eq!(tracker.current_phase_id(), None);
145    }
146}