1use std::error::Error;
4use std::fmt::{self, Display, Formatter};
5use std::fs;
6use std::path::Path;
7
8use gatecheck_types::{GateDefinition, GatePolicy, Requirement};
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum PolicyError {
13 Read(String),
14 Parse(String),
15}
16
17impl Display for PolicyError {
18 fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
19 match self {
20 Self::Read(message) | Self::Parse(message) => formatter.write_str(message),
21 }
22 }
23}
24
25impl Error for PolicyError {}
26
27pub fn load_policy(path: impl AsRef<Path>) -> Result<GatePolicy, PolicyError> {
29 let path = path.as_ref();
30 let contents = fs::read_to_string(path).map_err(|error| {
31 PolicyError::Read(format!("failed to read policy {}: {error}", path.display()))
32 })?;
33 parse_policy(&contents)
34}
35
36pub fn parse_policy(input: &str) -> Result<GatePolicy, PolicyError> {
38 let mut id = String::new();
39 let mut version = String::new();
40 let mut profile = String::new();
41 let mut gates = Vec::<GateDefinition>::new();
42 let mut current_gate: Option<GateDefinition> = None;
43
44 let lines: Vec<&str> = input.lines().collect();
45 let mut index = 0usize;
46 while index < lines.len() {
47 let line = strip_comments(lines[index]).trim();
48 index += 1;
49 if line.is_empty() {
50 continue;
51 }
52
53 if line.starts_with('[') && line.ends_with(']') {
54 if let Some(gate) = current_gate.take() {
55 gates.push(gate);
56 }
57 let section = &line[1..line.len() - 1];
58 let gate_id = section
59 .strip_prefix("gates.")
60 .ok_or_else(|| PolicyError::Parse(format!("unsupported section `{section}`")))?;
61 current_gate = Some(GateDefinition {
62 id: gate_id.to_owned(),
63 name: title_case(gate_id),
64 order: 0,
65 depends_on: Vec::new(),
66 requirements: Vec::new(),
67 });
68 continue;
69 }
70
71 let (key, value) = split_key_value(line)?;
72 match current_gate.as_mut() {
73 None => match key {
74 "id" => id = parse_string(value)?,
75 "version" => version = parse_string(value)?,
76 "profile" => profile = parse_string(value)?,
77 other => {
78 return Err(PolicyError::Parse(format!(
79 "unsupported top-level key `{other}`"
80 )))
81 }
82 },
83 Some(gate) => match key {
84 "name" => gate.name = parse_string(value)?,
85 "order" => gate.order = parse_u16(value)?,
86 "depends_on" => gate.depends_on = parse_string_array(value)?,
87 "requires" => {
88 let mut buffer = value.to_owned();
89 while bracket_delta(&buffer) > 0 {
90 if index >= lines.len() {
91 return Err(PolicyError::Parse(
92 "unterminated requires array".to_owned(),
93 ));
94 }
95 let next = strip_comments(lines[index]);
96 buffer.push('\n');
97 buffer.push_str(next);
98 index += 1;
99 }
100 gate.requirements = parse_requirements(&buffer)?;
101 }
102 other => {
103 return Err(PolicyError::Parse(format!(
104 "unsupported gate key `{other}`"
105 )))
106 }
107 },
108 }
109 }
110
111 if let Some(gate) = current_gate.take() {
112 gates.push(gate);
113 }
114
115 if id.is_empty() {
116 id = "gatecheck.policy".to_owned();
117 }
118 if version.is_empty() {
119 version = "1".to_owned();
120 }
121 if profile.is_empty() {
122 return Err(PolicyError::Parse("missing top-level `profile`".to_owned()));
123 }
124 if gates.is_empty() {
125 return Err(PolicyError::Parse(
126 "policy must declare at least one gate".to_owned(),
127 ));
128 }
129
130 gates.sort_by_key(|gate| gate.order);
131 for gate in &gates {
132 if gate.order == 0 {
133 return Err(PolicyError::Parse(format!(
134 "gate `{}` is missing `order`",
135 gate.id
136 )));
137 }
138 }
139 for window in gates.windows(2) {
140 if window[0].order == window[1].order {
141 return Err(PolicyError::Parse("gate orders must be unique".to_owned()));
142 }
143 }
144
145 Ok(GatePolicy {
146 id,
147 version,
148 profile,
149 gates,
150 })
151}
152
153fn strip_comments(line: &str) -> &str {
154 let mut in_string = false;
155 for (index, character) in line.char_indices() {
156 match character {
157 '"' => in_string = !in_string,
158 '#' if !in_string => return &line[..index],
159 _ => {}
160 }
161 }
162 line
163}
164
165fn split_key_value(line: &str) -> Result<(&str, &str), PolicyError> {
166 let mut in_string = false;
167 for (index, character) in line.char_indices() {
168 match character {
169 '"' => in_string = !in_string,
170 '=' if !in_string => {
171 let key = line[..index].trim();
172 let value = line[index + 1..].trim();
173 return Ok((key, value));
174 }
175 _ => {}
176 }
177 }
178 Err(PolicyError::Parse(format!(
179 "expected key = value, got `{line}`"
180 )))
181}
182
183fn bracket_delta(input: &str) -> i32 {
184 let mut delta = 0_i32;
185 let mut in_string = false;
186 for character in input.chars() {
187 match character {
188 '"' => in_string = !in_string,
189 '[' if !in_string => delta += 1,
190 ']' if !in_string => delta -= 1,
191 _ => {}
192 }
193 }
194 delta
195}
196
197fn parse_string(input: &str) -> Result<String, PolicyError> {
198 let trimmed = input.trim();
199 if !(trimmed.starts_with('"') && trimmed.ends_with('"')) {
200 return Err(PolicyError::Parse(format!(
201 "expected quoted string, got `{input}`"
202 )));
203 }
204 Ok(trimmed[1..trimmed.len() - 1].to_owned())
205}
206
207fn parse_u16(input: &str) -> Result<u16, PolicyError> {
208 input
209 .trim()
210 .parse::<u16>()
211 .map_err(|_| PolicyError::Parse(format!("expected integer, got `{input}`")))
212}
213
214fn parse_string_array(input: &str) -> Result<Vec<String>, PolicyError> {
215 let trimmed = input.trim();
216 if !(trimmed.starts_with('[') && trimmed.ends_with(']')) {
217 return Err(PolicyError::Parse(format!(
218 "expected string array, got `{input}`"
219 )));
220 }
221 let inner = &trimmed[1..trimmed.len() - 1];
222 if inner.trim().is_empty() {
223 return Ok(Vec::new());
224 }
225 split_top_level(inner, ',')
226 .into_iter()
227 .map(|piece| parse_string(piece.trim()))
228 .collect()
229}
230
231fn parse_requirements(input: &str) -> Result<Vec<Requirement>, PolicyError> {
232 let trimmed = input.trim();
233 if !(trimmed.starts_with('[') && trimmed.ends_with(']')) {
234 return Err(PolicyError::Parse(format!(
235 "expected array of inline tables, got `{input}`"
236 )));
237 }
238 let inner = &trimmed[1..trimmed.len() - 1];
239 let tables = split_inline_tables(inner)?;
240 tables
241 .into_iter()
242 .map(|table| parse_requirement_table(&table))
243 .collect()
244}
245
246fn split_inline_tables(input: &str) -> Result<Vec<String>, PolicyError> {
247 let mut tables = Vec::new();
248 let mut depth = 0_i32;
249 let mut in_string = false;
250 let mut start = None;
251
252 for (index, character) in input.char_indices() {
253 match character {
254 '"' => in_string = !in_string,
255 '{' if !in_string => {
256 if depth == 0 {
257 start = Some(index);
258 }
259 depth += 1;
260 }
261 '}' if !in_string => {
262 depth -= 1;
263 if depth == 0 {
264 if let Some(start_index) = start.take() {
265 tables.push(input[start_index..=index].to_owned());
266 }
267 }
268 }
269 _ => {}
270 }
271 }
272
273 if depth != 0 {
274 return Err(PolicyError::Parse(
275 "unbalanced inline table braces".to_owned(),
276 ));
277 }
278
279 Ok(tables)
280}
281
282fn parse_requirement_table(table: &str) -> Result<Requirement, PolicyError> {
283 let trimmed = table.trim();
284 if !(trimmed.starts_with('{') && trimmed.ends_with('}')) {
285 return Err(PolicyError::Parse(format!(
286 "expected inline table, got `{table}`"
287 )));
288 }
289 let inner = &trimmed[1..trimmed.len() - 1];
290 let pairs = split_top_level(inner, ',');
291 let mut kind = String::new();
292 let mut path = String::new();
293 let mut tool = String::new();
294 let mut check = String::new();
295 let mut name = String::new();
296 let mut key = String::new();
297 let mut min_count = None;
298
299 for pair in pairs {
300 let (field, value) = split_key_value(pair.trim())?;
301 match field {
302 "kind" => kind = parse_string(value)?,
303 "path" => path = parse_string(value)?,
304 "tool" => tool = parse_string(value)?,
305 "check" => check = parse_string(value)?,
306 "name" => name = parse_string(value)?,
307 "key" => key = parse_string(value)?,
308 "min_count" => min_count = Some(parse_u16(value)? as u8),
309 other => {
310 return Err(PolicyError::Parse(format!(
311 "unsupported requirement field `{other}`"
312 )))
313 }
314 }
315 }
316
317 match kind.as_str() {
318 "artifact_exists" => Ok(Requirement::ArtifactExists { path }),
319 "receipt_pass" => Ok(Requirement::ReceiptPass { tool, check }),
320 "issue_linked" => Ok(Requirement::IssueLinked),
321 "ci_check_passed" => Ok(Requirement::CiCheckPassed { name }),
322 "review_approved" => Ok(Requirement::ReviewApproved {
323 min_count: min_count.unwrap_or(1),
324 }),
325 "conversations_resolved" => Ok(Requirement::ConversationsResolved),
326 "attestation_present" => Ok(Requirement::AttestationPresent { key }),
327 other => Err(PolicyError::Parse(format!(
328 "unsupported requirement kind `{other}`"
329 ))),
330 }
331}
332
333fn split_top_level(input: &str, separator: char) -> Vec<&str> {
334 let mut pieces = Vec::new();
335 let mut in_string = false;
336 let mut brace_depth = 0_i32;
337 let mut bracket_depth = 0_i32;
338 let mut start = 0usize;
339
340 for (index, character) in input.char_indices() {
341 match character {
342 '"' => in_string = !in_string,
343 '{' if !in_string => brace_depth += 1,
344 '}' if !in_string => brace_depth -= 1,
345 '[' if !in_string => bracket_depth += 1,
346 ']' if !in_string => bracket_depth -= 1,
347 _ if !in_string && brace_depth == 0 && bracket_depth == 0 && character == separator => {
348 pieces.push(input[start..index].trim());
349 start = index + character.len_utf8();
350 }
351 _ => {}
352 }
353 }
354
355 let tail = input[start..].trim();
356 if !tail.is_empty() {
357 pieces.push(tail);
358 }
359
360 pieces
361}
362
363fn title_case(input: &str) -> String {
364 input
365 .split(['-', '_'])
366 .filter(|part| !part.is_empty())
367 .map(|part| {
368 let mut chars = part.chars();
369 match chars.next() {
370 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
371 None => String::new(),
372 }
373 })
374 .collect::<Vec<_>>()
375 .join(" ")
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 #[test]
383 fn given_valid_policy_toml_when_parse_then_gates_are_sorted() {
384 let policy = parse_policy(
385 r#"
386 profile = "conveyor-6"
387
388 [gates.verified]
389 order = 2
390 requires = [{ kind = "artifact_exists", path = "verified.md" }]
391
392 [gates.framed]
393 order = 1
394 requires = [{ kind = "issue_linked" }]
395 "#,
396 )
397 .expect("policy");
398
399 assert_eq!(policy.gates[0].id, "framed");
400 assert_eq!(policy.gates[1].id, "verified");
401 assert_eq!(policy.gates[0].name, "Framed");
402 }
403
404 #[test]
405 fn given_duplicate_order_when_parse_then_error_is_returned() {
406 let error = parse_policy(
407 r#"
408 profile = "conveyor-6"
409
410 [gates.a]
411 order = 1
412 requires = [{ kind = "issue_linked" }]
413
414 [gates.b]
415 order = 1
416 requires = [{ kind = "issue_linked" }]
417 "#,
418 )
419 .expect_err("duplicate order");
420
421 assert!(matches!(error, PolicyError::Parse(_)));
422 }
423}