1use anyhow::{anyhow, Result};
7
8#[derive(Debug, Default, Clone)]
10pub struct ProjectState {
11 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 pub active_attempts: usize,
21 pub hacks_count: usize,
22 pub expired_hacks: usize,
23
24 pub domains_count: usize,
26 pub layers_count: usize,
27 pub entry_points_count: usize,
28 pub variables_count: usize,
29
30 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
106pub 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 "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 "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 "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 pub fn from_cache(cache: &crate::cache::Cache) -> Self {
184 let mut state = ProjectState::default();
185
186 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 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, });
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 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 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 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(), description: domain.description.clone(),
268 });
269 }
270
271 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}