1use std::collections::HashSet;
5
6use serde::de::DeserializeOwned;
7
8use crate::ParseError;
9
10const DIVERGENT_BOOLISH: &[&str] = &["yes", "no", "on", "off"];
11const LEFT_BRACE_BYTE: u8 = b'{';
12
13pub fn parse_yaml_document<T>(source: &str) -> Result<T, ParseError>
14where
15 T: DeserializeOwned,
16{
17 assert_yaml_parity_subset("yaml", source)?;
18 serde_norway::from_str(source).map_err(|error| ParseError::InvalidYaml {
19 field: "yaml".to_owned(),
20 message: error.to_string(),
21 })
22}
23
24pub fn assert_yaml_parity_subset(field: &str, source: &str) -> Result<(), ParseError> {
25 let mut block_scalar_indent = None;
26 for (line_index, line) in source.lines().enumerate() {
27 let line_number = line_index + 1;
28 let Some(content) = strip_yaml_comment(line) else {
29 continue;
30 };
31 let trimmed = content.trim();
32 if let Some(indent) = block_scalar_indent {
33 if trimmed.is_empty() || leading_spaces(content) > indent {
34 continue;
35 }
36 block_scalar_indent = None;
37 }
38 if trimmed.is_empty() || trimmed.starts_with("---") || trimmed.starts_with("...") {
39 continue;
40 }
41 reject_explicit_mapping_key(field, line_number, trimmed)?;
42 reject_embedded_colon_key(field, line_number, trimmed)?;
43 reject_colon_space_plain_scalar(field, line_number, content)?;
44 block_scalar_indent = block_scalar_indent_after(content).or(block_scalar_indent);
45 }
46 Ok(())
47}
48
49pub fn assert_execution_profile_yaml_subset(field: &str, source: &str) -> Result<(), ParseError> {
50 assert_yaml_parity_subset(field, source)?;
51 let mut mapping_stack = Vec::new();
52 let mut block_scalar_indent = None;
53 for (line_index, line) in source.lines().enumerate() {
54 let line_number = line_index + 1;
55 let Some(content) = strip_yaml_comment(line) else {
56 continue;
57 };
58 let trimmed = content.trim();
59 if let Some(indent) = block_scalar_indent {
60 if trimmed.is_empty() || leading_spaces(content) > indent {
61 continue;
62 }
63 block_scalar_indent = None;
64 }
65 if trimmed.is_empty() {
66 continue;
67 }
68 reject_document_marker(field, line_number, trimmed)?;
69 reject_yaml_reference_syntax(field, line_number, content)?;
70 reject_duplicate_mapping_key(field, line_number, content, &mut mapping_stack)?;
71 block_scalar_indent = block_scalar_indent_after(content).or(block_scalar_indent);
72 }
73 Ok(())
74}
75
76#[must_use]
88pub fn yaml_scalar_subset_allows(literal: &str) -> bool {
89 let trimmed = literal.trim();
90 !is_boolish(trimmed)
91 && !is_base_prefixed_number(trimmed)
92 && !is_sexagesimal_like(trimmed)
93 && !is_date_like(trimmed)
94 && !is_special_float(trimmed)
95}
96
97pub fn assert_yaml_scalar_subset(field: &str, literal: &str) -> Result<(), ParseError> {
98 if yaml_scalar_subset_allows(literal) {
99 return Ok(());
100 }
101 Err(ParseError::UnsupportedScalar {
102 field: field.to_owned(),
103 literal: literal.to_owned(),
104 })
105}
106
107fn strip_yaml_comment(line: &str) -> Option<&str> {
108 let mut scanner = QuoteScanner::new();
109 for (index, char) in line.char_indices() {
110 if scanner.is_plain_at(char) && char == '#' && is_comment_start(line, index) {
111 return Some(&line[..index]);
112 }
113 scanner.consume(char);
114 }
115 Some(line)
116}
117
118fn is_comment_start(line: &str, index: usize) -> bool {
119 index == 0 || line[..index].ends_with(char::is_whitespace)
120}
121
122fn reject_explicit_mapping_key(
123 field: &str,
124 line_number: usize,
125 trimmed: &str,
126) -> Result<(), ParseError> {
127 if trimmed == "?" || trimmed.starts_with("? ") {
128 return Err(ambiguous_yaml(field, line_number, trimmed));
129 }
130 Ok(())
131}
132
133fn reject_embedded_colon_key(
134 field: &str,
135 line_number: usize,
136 trimmed: &str,
137) -> Result<(), ParseError> {
138 let Some((key, _)) = top_level_plain_key(trimmed) else {
139 return Ok(());
140 };
141 if key.contains(':') {
142 return Err(ambiguous_yaml(field, line_number, trimmed));
143 }
144 Ok(())
145}
146
147fn top_level_plain_key(trimmed: &str) -> Option<(&str, usize)> {
148 let bytes = trimmed.as_bytes();
149 if bytes
150 .first()
151 .is_some_and(|byte| matches!(*byte, b'-' | b'?' | LEFT_BRACE_BYTE | b'[' | b'"' | b'\''))
152 {
153 return None;
154 }
155 let mut scanner = QuoteScanner::new();
156 for (index, char) in trimmed.char_indices() {
157 if scanner.is_plain_at(char) && char == ':' && is_mapping_delimiter(trimmed, index) {
158 return Some((trimmed[..index].trim(), index));
159 }
160 scanner.consume(char);
161 }
162 None
163}
164
165#[derive(Clone, Copy)]
174enum QuoteState {
175 Plain,
176 InDouble,
177 InSinglePendingApostrophe,
180 InSingle,
181 InDoubleEscape,
184}
185
186struct QuoteScanner {
187 state: QuoteState,
188}
189
190impl QuoteScanner {
191 fn new() -> Self {
192 Self {
193 state: QuoteState::Plain,
194 }
195 }
196
197 fn is_plain_at(&self, char: char) -> bool {
198 match self.state {
203 QuoteState::Plain => true,
204 QuoteState::InSinglePendingApostrophe => char != '\'',
205 QuoteState::InDouble | QuoteState::InDoubleEscape | QuoteState::InSingle => false,
206 }
207 }
208
209 fn consume(&mut self, char: char) {
210 self.state = match self.state {
211 QuoteState::Plain => Self::plain_state_after(char),
212 QuoteState::InDouble => match char {
213 '\\' => QuoteState::InDoubleEscape,
214 '"' => QuoteState::Plain,
215 _ => QuoteState::InDouble,
216 },
217 QuoteState::InDoubleEscape => QuoteState::InDouble,
218 QuoteState::InSingle => match char {
219 '\'' => QuoteState::InSinglePendingApostrophe,
220 _ => QuoteState::InSingle,
221 },
222 QuoteState::InSinglePendingApostrophe => match char {
226 '\'' => QuoteState::InSingle,
227 _ => Self::plain_state_after(char),
228 },
229 };
230 }
231
232 fn plain_state_after(char: char) -> QuoteState {
233 match char {
234 '\'' => QuoteState::InSingle,
235 '"' => QuoteState::InDouble,
236 _ => QuoteState::Plain,
237 }
238 }
239}
240
241fn is_mapping_delimiter(value: &str, index: usize) -> bool {
242 value[index + 1..]
243 .chars()
244 .next()
245 .is_none_or(char::is_whitespace)
246}
247
248fn reject_colon_space_plain_scalar(
249 field: &str,
250 line_number: usize,
251 content: &str,
252) -> Result<(), ParseError> {
253 let Some((_, value)) = split_plain_mapping_value(content) else {
254 return Ok(());
255 };
256 if plain_scalar_contains_colon_space(value) {
257 return Err(ambiguous_yaml(field, line_number, value.trim()));
258 }
259 Ok(())
260}
261
262fn reject_document_marker(
263 field: &str,
264 line_number: usize,
265 trimmed: &str,
266) -> Result<(), ParseError> {
267 if trimmed == "---"
268 || trimmed == "..."
269 || trimmed.starts_with("--- ")
270 || trimmed.starts_with("... ")
271 {
272 return Err(ParseError::InvalidYaml {
273 field: field.to_owned(),
274 message: format!(
275 "YAML document markers are not supported in X.yaml at line {line_number}; use one plain profile document."
276 ),
277 });
278 }
279 Ok(())
280}
281
282fn reject_yaml_reference_syntax(
283 field: &str,
284 line_number: usize,
285 content: &str,
286) -> Result<(), ParseError> {
287 for token in [": &", ": *", ": !", "- &", "- *", "- !"] {
288 if contains_plain_token(content, token) {
289 return Err(ParseError::InvalidYaml {
290 field: field.to_owned(),
291 message: format!(
292 "YAML anchors, aliases, and tags are not supported in X.yaml at line {line_number}; write the profile explicitly."
293 ),
294 });
295 }
296 }
297 let trimmed = content.trim_start();
298 if trimmed.starts_with(['&', '*', '!']) {
299 return Err(ParseError::InvalidYaml {
300 field: field.to_owned(),
301 message: format!(
302 "YAML anchors, aliases, and tags are not supported in X.yaml at line {line_number}; write the profile explicitly."
303 ),
304 });
305 }
306 Ok(())
307}
308
309fn contains_plain_token(content: &str, token: &str) -> bool {
310 let mut scanner = QuoteScanner::new();
311 for (index, char) in content.char_indices() {
312 if scanner.is_plain_at(char) && content[index..].starts_with(token) {
313 return true;
314 }
315 scanner.consume(char);
316 }
317 false
318}
319
320struct MappingFrame {
321 indent: usize,
322 keys: HashSet<String>,
323}
324
325fn reject_duplicate_mapping_key(
326 field: &str,
327 line_number: usize,
328 content: &str,
329 stack: &mut Vec<MappingFrame>,
330) -> Result<(), ParseError> {
331 let indent = leading_spaces(content);
332 let trimmed = content.trim_start();
333 let (key_indent, key, sequence_item) = match sequence_item_key(trimmed, indent) {
334 Some(value) => value,
335 None => {
336 let Some((key, _)) = top_level_plain_key(trimmed) else {
337 return Ok(());
338 };
339 (indent, key, false)
340 }
341 };
342 if key == "<<" {
343 return Err(ParseError::InvalidYaml {
344 field: field.to_owned(),
345 message: format!(
346 "YAML merge keys are not supported in X.yaml at line {line_number}; write the profile explicitly."
347 ),
348 });
349 }
350 if sequence_item {
351 while stack.last().is_some_and(|frame| frame.indent >= key_indent) {
352 stack.pop();
353 }
354 } else {
355 while stack.last().is_some_and(|frame| frame.indent > key_indent) {
356 stack.pop();
357 }
358 }
359 if stack.last().is_none_or(|frame| frame.indent != key_indent) {
360 stack.push(MappingFrame {
361 indent: key_indent,
362 keys: HashSet::new(),
363 });
364 }
365 let Some(frame) = stack.last_mut() else {
366 return Err(ParseError::InvalidYaml {
367 field: field.to_owned(),
368 message: format!("could not track mapping key {key:?} in X.yaml at line {line_number}"),
369 });
370 };
371 if !frame.keys.insert(key.to_owned()) {
372 return Err(ParseError::InvalidYaml {
373 field: field.to_owned(),
374 message: format!(
375 "duplicate mapping key {key:?} in X.yaml at line {line_number}; keep profile keys unique."
376 ),
377 });
378 }
379 Ok(())
380}
381
382fn sequence_item_key(trimmed: &str, indent: usize) -> Option<(usize, &str, bool)> {
383 let rest = trimmed.strip_prefix("- ")?;
384 let item = rest.trim_start();
385 let leading = rest.len() - item.len();
386 let (key, _) = top_level_plain_key(item)?;
387 Some((indent + 2 + leading, key, true))
388}
389
390fn leading_spaces(content: &str) -> usize {
391 content.bytes().take_while(|byte| *byte == b' ').count()
392}
393
394fn block_scalar_indent_after(content: &str) -> Option<usize> {
395 block_scalar_value_candidates(content)
396 .iter()
397 .any(|value| is_block_scalar_header(value))
398 .then(|| leading_spaces(content))
399}
400
401fn block_scalar_value_candidates(content: &str) -> Vec<&str> {
402 let mut candidates = Vec::new();
403 if let Some((_, value)) = split_plain_mapping_value(content) {
404 candidates.push(value);
405 }
406 let trimmed = content.trim_start();
407 if let Some(rest) = trimmed.strip_prefix("- ") {
408 let item = rest.trim_start();
409 candidates.push(item);
410 if let Some((_, value)) = split_plain_mapping_value(item) {
411 candidates.push(value);
412 }
413 }
414 candidates
415}
416
417fn is_block_scalar_header(value: &str) -> bool {
418 let trimmed = value.trim();
419 let mut chars = trimmed.chars();
420 let Some(first) = chars.next() else {
421 return false;
422 };
423 if !matches!(first, '|' | '>') {
424 return false;
425 }
426 let mut seen_chomp = false;
427 let mut seen_indent = false;
428 for char in chars {
429 if matches!(char, '+' | '-') && !seen_chomp {
430 seen_chomp = true;
431 } else if char.is_ascii_digit() && !seen_indent {
432 seen_indent = true;
433 } else {
434 return false;
435 }
436 }
437 true
438}
439
440fn split_plain_mapping_value(content: &str) -> Option<(&str, &str)> {
441 let trimmed = content.trim_start();
442 let (key, delimiter_index) = top_level_plain_key(trimmed)?;
443 Some((key, &trimmed[delimiter_index + 1..]))
444}
445
446fn plain_scalar_contains_colon_space(value: &str) -> bool {
449 let trimmed = value.trim_start();
450 if trimmed.is_empty()
451 || trimmed.starts_with(['"', '\'', '|', '>', '{', '['])
452 || trimmed == "null"
453 || matches!(trimmed, "true" | "false")
454 {
455 return false;
456 }
457 contains_unquoted_colon_space(trimmed)
458}
459
460fn contains_unquoted_colon_space(value: &str) -> bool {
461 let mut scanner = QuoteScanner::new();
462 for (index, char) in value.char_indices() {
463 if scanner.is_plain_at(char) && char == ':' && is_mapping_delimiter(value, index) {
464 return true;
465 }
466 scanner.consume(char);
467 }
468 false
469}
470
471fn ambiguous_yaml(field: &str, line_number: usize, literal: &str) -> ParseError {
472 ParseError::InvalidYaml {
473 field: field.to_owned(),
474 message: format!(
475 "ambiguous YAML construct at line {line_number}; quote the value or key: {literal}"
476 ),
477 }
478}
479
480fn is_boolish(value: &str) -> bool {
481 DIVERGENT_BOOLISH
482 .iter()
483 .any(|candidate| value.eq_ignore_ascii_case(candidate))
484}
485
486fn is_base_prefixed_number(value: &str) -> bool {
487 let unsigned = value.strip_prefix(['+', '-']).unwrap_or(value);
488 unsigned.starts_with("0x") || unsigned.starts_with("0X") || unsigned.starts_with("0o")
489}
490
491fn is_sexagesimal_like(value: &str) -> bool {
492 let unsigned = value.strip_prefix(['+', '-']).unwrap_or(value);
493 let mut parts = unsigned.split(':');
494 let Some(first) = parts.next() else {
495 return false;
496 };
497 first.chars().all(|char| char.is_ascii_digit())
498 && parts.clone().count() > 0
499 && parts.all(|part| !part.is_empty() && part.chars().all(|char| char.is_ascii_digit()))
500}
501
502fn is_date_like(value: &str) -> bool {
503 let bytes = value.as_bytes();
504 bytes.len() >= 10
505 && bytes[0..4].iter().all(u8::is_ascii_digit)
506 && bytes[4] == b'-'
507 && bytes[5..7].iter().all(u8::is_ascii_digit)
508 && bytes[7] == b'-'
509 && bytes[8..10].iter().all(u8::is_ascii_digit)
510}
511
512fn is_special_float(value: &str) -> bool {
513 matches!(
514 value.to_ascii_lowercase().as_str(),
515 ".nan" | ".inf" | "+.inf" | "-.inf"
516 )
517}
518
519#[cfg(test)]
520mod tests {
521 use super::{
522 assert_execution_profile_yaml_subset, assert_yaml_parity_subset, assert_yaml_scalar_subset,
523 yaml_scalar_subset_allows,
524 };
525
526 #[test]
527 fn scalar_subset_rejects_divergent_forms() {
528 for literal in ["yes", "ON", "0x10", "0o10", "12:34", "2026-05-18", ".nan"] {
529 assert!(!yaml_scalar_subset_allows(literal), "{literal}");
530 }
531 }
532
533 #[test]
534 fn scalar_subset_allows_explicit_json_like_scalars() -> Result<(), crate::ParseError> {
535 for literal in ["true", "false", "1", "1.5", "plain text", "\"yes\""] {
536 assert_yaml_scalar_subset("fixture", literal)?;
537 }
538 Ok(())
539 }
540
541 #[test]
547 fn parity_subset_accepts_backslash_escape_in_double_quote() -> Result<(), crate::ParseError> {
548 for literal in [
549 "key: \"a\\\\b\"",
550 "key: \"trailing\\\\\"",
551 "key: \"mid\\\\\"",
552 "key: \"\\\\\"",
553 ] {
554 assert_yaml_parity_subset("fixture", literal)?;
555 }
556 Ok(())
557 }
558
559 #[test]
560 fn parity_subset_rejects_colon_space_after_closed_double_quote_with_escapes() {
561 let result =
570 assert_yaml_parity_subset("fixture", "key: plain \"escaped\\\\\" trailing: oops");
571 assert!(result.is_err(), "expected rejection, got {result:?}");
572 }
573
574 #[test]
575 fn parity_subset_rejects_explicit_mapping_keys() {
576 let result = assert_yaml_parity_subset("fixture", "? >\r 2>-: ");
577 assert!(result.is_err(), "expected rejection, got {result:?}");
578 }
579
580 #[test]
581 fn execution_profile_subset_rejects_yaml_references_and_document_markers() {
582 for literal in [
583 "---\nskill: example",
584 "runners:\n one:\n outputs: &shared\n result: string",
585 "runners:\n one:\n outputs: *shared",
586 "runners:\n one:\n runx:\n <<: *shared",
587 "runners:\n one:\n type: !custom graph",
588 ] {
589 let result = assert_execution_profile_yaml_subset("runner_manifest", literal);
590 assert!(result.is_err(), "expected rejection, got {result:?}");
591 }
592 }
593
594 #[test]
595 fn execution_profile_subset_rejects_duplicate_keys_but_allows_sequence_reuse() {
596 let result =
597 assert_execution_profile_yaml_subset("runner_manifest", "skill: one\nskill: two\n");
598 assert!(
599 result.is_err(),
600 "expected duplicate key rejection, got {result:?}"
601 );
602
603 let sequence_result = assert_execution_profile_yaml_subset(
604 "runner_manifest",
605 r#"
606runners:
607 demo:
608 type: graph
609 graph:
610 name: demo
611 steps:
612 - id: first
613 tool: one.tool
614 - id: second
615 tool: two.tool
616"#,
617 );
618 assert!(
619 sequence_result.is_ok(),
620 "sequence item maps may reuse keys in separate items: {sequence_result:?}"
621 );
622 }
623
624 #[test]
628 fn parity_subset_handles_single_quote_double_escape() -> Result<(), crate::ParseError> {
629 for literal in ["key: 'it''s'", "key: 'a''b''c'", "key: ''"] {
630 assert_yaml_parity_subset("fixture", literal)?;
631 }
632 Ok(())
633 }
634
635 #[test]
636 fn parity_subset_keeps_unicode_mapping_delimiter_on_char_boundary()
637 -> Result<(), crate::ParseError> {
638 assert_yaml_parity_subset("fixture", "\0\0\0'\0\0\0\0\u{8}'|\u{85}:")?;
639 Ok(())
640 }
641
642 #[test]
643 fn parity_subset_rejects_colon_space_after_closed_single_quote_with_escapes() {
644 let result = assert_yaml_parity_subset("fixture", "key: plain 'it''s' trailing: oops");
652 assert!(result.is_err(), "expected rejection, got {result:?}");
653 }
654}