acp/primer/
condition.rs

1//! @acp:module "Primer Condition Parser"
2//! @acp:summary "Parse and evaluate condition expressions for requiredIf and value modifiers"
3//! @acp:domain cli
4//! @acp:layer logic
5
6use anyhow::{anyhow, Result};
7
8/// Project state extracted from cache for condition evaluation
9#[derive(Debug, Default, Clone)]
10pub struct ProjectState {
11    // Constraint counts
12    pub frozen_count: usize,
13    pub restricted_count: usize,
14    pub approval_count: usize,
15    pub tests_required_count: usize,
16    pub docs_required_count: usize,
17    pub protected_count: usize,
18
19    // Debug/attempt counts
20    pub active_attempts: usize,
21    pub hacks_count: usize,
22    pub expired_hacks: usize,
23
24    // Structure counts
25    pub domains_count: usize,
26    pub layers_count: usize,
27    pub entry_points_count: usize,
28    pub variables_count: usize,
29
30    // Dynamic data for sections
31    pub frozen_files: Vec<ProtectedFile>,
32    pub restricted_files: Vec<ProtectedFile>,
33    pub domains: Vec<DomainInfo>,
34    pub layers: Vec<LayerInfo>,
35    pub entry_points: Vec<EntryPointInfo>,
36    pub variables: Vec<VariableInfo>,
37    pub active_attempt_list: Vec<AttemptInfo>,
38    pub hacks: Vec<HackInfo>,
39}
40
41#[derive(Debug, Clone)]
42pub struct ProtectedFile {
43    pub path: String,
44    pub level: String,
45    pub reason: Option<String>,
46}
47
48#[derive(Debug, Clone)]
49pub struct DomainInfo {
50    pub name: String,
51    pub pattern: String,
52    pub description: Option<String>,
53}
54
55#[derive(Debug, Clone)]
56pub struct LayerInfo {
57    pub name: String,
58    pub pattern: String,
59}
60
61#[derive(Debug, Clone)]
62pub struct EntryPointInfo {
63    pub name: String,
64    pub path: String,
65}
66
67#[derive(Debug, Clone)]
68pub struct VariableInfo {
69    pub name: String,
70    pub value: String,
71    pub description: Option<String>,
72}
73
74#[derive(Debug, Clone)]
75pub struct AttemptInfo {
76    pub id: String,
77    pub problem: String,
78    pub attempt_count: usize,
79}
80
81#[derive(Debug, Clone)]
82pub struct HackInfo {
83    pub file: String,
84    pub reason: String,
85    pub expires: Option<String>,
86    pub expired: bool,
87}
88
89#[derive(Debug)]
90enum Operator {
91    Eq,
92    Ne,
93    Gt,
94    Gte,
95    Lt,
96    Lte,
97}
98
99#[derive(Debug)]
100struct Condition {
101    path: String,
102    operator: Operator,
103    value: i64,
104}
105
106/// Evaluate a condition expression against project state
107///
108/// Supports expressions like:
109/// - "constraints.frozenCount > 0"
110/// - "hacks.expiredCount > 0"
111/// - "attempts.activeCount == 0"
112pub fn evaluate_condition(expr: &str, state: &ProjectState) -> Result<bool> {
113    let condition = parse_condition(expr)?;
114    let actual_value = resolve_path(&condition.path, state)?;
115
116    Ok(match condition.operator {
117        Operator::Eq => actual_value == condition.value,
118        Operator::Ne => actual_value != condition.value,
119        Operator::Gt => actual_value > condition.value,
120        Operator::Gte => actual_value >= condition.value,
121        Operator::Lt => actual_value < condition.value,
122        Operator::Lte => actual_value <= condition.value,
123    })
124}
125
126fn parse_condition(expr: &str) -> Result<Condition> {
127    let parts: Vec<&str> = expr.split_whitespace().collect();
128    if parts.len() != 3 {
129        return Err(anyhow!(
130            "Invalid condition '{}': expected 'path operator value'",
131            expr
132        ));
133    }
134
135    let path = parts[0].to_string();
136    let operator = match parts[1] {
137        "==" => Operator::Eq,
138        "!=" => Operator::Ne,
139        ">" => Operator::Gt,
140        ">=" => Operator::Gte,
141        "<" => Operator::Lt,
142        "<=" => Operator::Lte,
143        op => return Err(anyhow!("Unknown operator '{}' in condition", op)),
144    };
145    let value = parts[2]
146        .parse::<i64>()
147        .map_err(|_| anyhow!("Invalid numeric value '{}' in condition", parts[2]))?;
148
149    Ok(Condition {
150        path,
151        operator,
152        value,
153    })
154}
155
156fn resolve_path(path: &str, state: &ProjectState) -> Result<i64> {
157    match path {
158        // Constraint paths
159        "constraints.frozenCount" => Ok(state.frozen_count as i64),
160        "constraints.restrictedCount" => Ok(state.restricted_count as i64),
161        "constraints.approvalCount" => Ok(state.approval_count as i64),
162        "constraints.testsRequiredCount" => Ok(state.tests_required_count as i64),
163        "constraints.docsRequiredCount" => Ok(state.docs_required_count as i64),
164        "constraints.protectedCount" => Ok(state.protected_count as i64),
165
166        // Attempt/debug paths
167        "attempts.activeCount" => Ok(state.active_attempts as i64),
168        "hacks.count" => Ok(state.hacks_count as i64),
169        "hacks.expiredCount" => Ok(state.expired_hacks as i64),
170
171        // Structure paths
172        "domains.count" => Ok(state.domains_count as i64),
173        "layers.count" => Ok(state.layers_count as i64),
174        "entryPoints.count" => Ok(state.entry_points_count as i64),
175        "variables.count" => Ok(state.variables_count as i64),
176
177        _ => Err(anyhow!("Unknown condition path: {}", path)),
178    }
179}
180
181impl ProjectState {
182    /// Create ProjectState from cache data
183    pub fn from_cache(cache: &crate::cache::Cache) -> Self {
184        let mut state = ProjectState::default();
185
186        // Extract constraint counts
187        if let Some(constraints) = &cache.constraints {
188            let by_level = &constraints.by_lock_level;
189            state.frozen_count = by_level
190                .get("frozen")
191                .map(|v: &Vec<String>| v.len())
192                .unwrap_or(0);
193            state.restricted_count = by_level
194                .get("restricted")
195                .map(|v: &Vec<String>| v.len())
196                .unwrap_or(0);
197            state.approval_count = by_level
198                .get("approval-required")
199                .map(|v: &Vec<String>| v.len())
200                .unwrap_or(0);
201            state.tests_required_count = by_level
202                .get("tests-required")
203                .map(|v: &Vec<String>| v.len())
204                .unwrap_or(0);
205            state.docs_required_count = by_level
206                .get("docs-required")
207                .map(|v: &Vec<String>| v.len())
208                .unwrap_or(0);
209
210            state.protected_count = state.frozen_count + state.restricted_count;
211
212            // Build protected files list
213            if let Some(frozen) = by_level.get("frozen") {
214                for path in frozen {
215                    state.frozen_files.push(ProtectedFile {
216                        path: path.clone(),
217                        level: "frozen".to_string(),
218                        reason: None, // Would need to look up in by_file
219                    });
220                }
221            }
222            if let Some(restricted) = by_level.get("restricted") {
223                for path in restricted {
224                    state.restricted_files.push(ProtectedFile {
225                        path: path.clone(),
226                        level: "restricted".to_string(),
227                        reason: None,
228                    });
229                }
230            }
231
232            // Extract hacks info
233            state.hacks_count = constraints.hacks.len();
234            state.expired_hacks = constraints.hacks.iter().filter(|h| h.is_expired()).count();
235            for hack in &constraints.hacks {
236                state.hacks.push(HackInfo {
237                    file: hack.file.clone(),
238                    reason: hack.reason.clone(),
239                    expires: hack.expires.map(|e| e.to_rfc3339()),
240                    expired: hack.is_expired(),
241                });
242            }
243
244            // Extract active debug sessions
245            state.active_attempts = constraints
246                .debug_sessions
247                .iter()
248                .filter(|s| s.status == crate::constraints::DebugStatus::Active)
249                .count();
250            for session in &constraints.debug_sessions {
251                if session.status == crate::constraints::DebugStatus::Active {
252                    state.active_attempt_list.push(AttemptInfo {
253                        id: session.id.clone(),
254                        problem: session.problem.clone(),
255                        attempt_count: session.attempts.len(),
256                    });
257                }
258            }
259        }
260
261        // Extract domain info (domains is a HashMap, not Option)
262        state.domains_count = cache.domains.len();
263        for (name, domain) in &cache.domains {
264            state.domains.push(DomainInfo {
265                name: name.clone(),
266                pattern: domain.files.first().cloned().unwrap_or_default(), // Use first file as pattern
267                description: domain.description.clone(),
268            });
269        }
270
271        // Note: Variables are not stored in cache, they come from a separate vars file
272        // This would require loading the vars file separately if needed
273
274        state
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn test_condition_greater_than() {
284        let state = ProjectState {
285            frozen_count: 5,
286            ..Default::default()
287        };
288        assert!(evaluate_condition("constraints.frozenCount > 0", &state).unwrap());
289        assert!(!evaluate_condition("constraints.frozenCount > 10", &state).unwrap());
290    }
291
292    #[test]
293    fn test_condition_equals_zero() {
294        let state = ProjectState::default();
295        assert!(evaluate_condition("hacks.count == 0", &state).unwrap());
296    }
297
298    #[test]
299    fn test_condition_less_than_or_equal() {
300        let state = ProjectState {
301            active_attempts: 3,
302            ..Default::default()
303        };
304        assert!(evaluate_condition("attempts.activeCount <= 5", &state).unwrap());
305        assert!(!evaluate_condition("attempts.activeCount <= 2", &state).unwrap());
306    }
307
308    #[test]
309    fn test_condition_unknown_path_errors() {
310        let state = ProjectState::default();
311        assert!(evaluate_condition("unknown.path > 0", &state).is_err());
312    }
313
314    #[test]
315    fn test_condition_invalid_syntax_errors() {
316        let state = ProjectState::default();
317        assert!(evaluate_condition("invalid", &state).is_err());
318        assert!(evaluate_condition("path >", &state).is_err());
319    }
320}