1use std::collections::{HashMap, HashSet};
2use std::fs;
3use std::panic::AssertUnwindSafe;
4use std::path::{Path, PathBuf};
5
6use yaml_rust2::parser::{MarkedEventReceiver, Parser};
7use yaml_rust2::scanner::Marker;
8use yaml_rust2::Event;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Workflow {
15 pub path: PathBuf,
16 pub on_line: Option<usize>,
18 pub has_pull_request: bool,
19 pub has_pull_request_target: bool,
21 pub cancel_in_progress: bool,
24 pub concurrency_line: Option<usize>,
26 pub jobs: Vec<Job>,
27 pub duplicate_keys: Vec<DuplicateKey>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct Job {
34 pub name: String,
35 pub line: usize,
37 pub display_name: Option<String>,
39 pub runs_on: Option<RunsOn>,
40 pub has_timeout_minutes: bool,
41 pub steps_text: String,
43 pub steps: Vec<Step>,
45 pub matrix: Option<Matrix>,
47 pub cancel_in_progress: bool,
49 pub concurrency_line: Option<usize>,
51 pub uses: Option<ReusableCall>,
53 pub invalid_uses: Option<InvalidUses>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct Step {
70 pub name: String,
71 pub line: usize,
72 pub local_action: Option<LocalAction>,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct LocalAction {
82 pub line: usize,
84 pub spec: String,
86 pub relative: String,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct ParsedAction {
94 pub path: PathBuf,
95 pub using: String,
97 pub steps: Vec<ActionStep>,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct ActionStep {
103 pub name: String,
104 pub name_line: Option<usize>,
105 pub run_text: String,
106 pub run_line: Option<usize>,
107 pub local_action: Option<LocalAction>,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct ReusableCall {
113 pub line: usize,
115 pub spec: String,
117 pub file_name: String,
119 pub source: ReusableSource,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum ReusableSource {
126 Local,
128 Remote(RemoteWorkflow),
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct RemoteWorkflow {
135 pub owner: String,
136 pub repo: String,
137 pub workflow_path: String,
139 pub reference: String,
141}
142
143impl RemoteWorkflow {
144 pub fn cache_relative(&self) -> PathBuf {
149 let mut path = PathBuf::new();
150 path.push(&self.owner);
151 path.push(&self.repo);
152 let mut parts = self
153 .workflow_path
154 .split('/')
155 .filter(|part| !part.is_empty());
156 let file = parts.next_back().unwrap_or("");
157 for part in parts {
158 path.push(part);
159 }
160 let mut refs = self.reference.split('/').filter(|part| !part.is_empty());
161 let first = refs.next().unwrap_or("");
162 path.push(format!("{file}@{first}"));
163 for part in refs {
164 path.push(part);
165 }
166 path
167 }
168}
169
170#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct InvalidUses {
173 pub line: usize,
175 pub detail: String,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct RunsOn {
182 pub line: usize,
184 pub value: String,
186 pub labels: Vec<String>,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct Matrix {
198 pub line: usize,
200 pub jobs: u64,
202 pub at_least: bool,
203 pub jobs_hi: Option<u64>,
205 pub axes: Vec<MatrixAxis>,
207}
208
209#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct MatrixAxis {
212 pub name: String,
213 pub line: usize,
215 pub values: Vec<Vec<String>>,
217}
218
219#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct DuplicateKey {
222 pub key: String,
223 pub first: usize,
225 pub second: usize,
227}
228
229#[derive(Debug, Clone, PartialEq, Eq)]
231pub struct ScanError {
232 pub path: PathBuf,
233 kind: ScanErrorKind,
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237enum ScanErrorKind {
238 Directory,
239 File,
240 Yaml { line: usize },
241}
242
243impl std::fmt::Display for ScanError {
244 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245 match self.kind {
246 ScanErrorKind::Directory => write!(f, "cannot read directory"),
247 ScanErrorKind::File => write!(f, "cannot read file"),
248 ScanErrorKind::Yaml { line } => write!(f, "line {line}: invalid YAML"),
249 }
250 }
251}
252
253impl std::error::Error for ScanError {}
254
255impl ScanError {
256 pub(crate) fn directory(path: &Path) -> Self {
257 Self {
258 path: path.to_path_buf(),
259 kind: ScanErrorKind::Directory,
260 }
261 }
262
263 pub(crate) fn file(path: &Path) -> Self {
264 Self {
265 path: path.to_path_buf(),
266 kind: ScanErrorKind::File,
267 }
268 }
269
270 fn yaml(path: &Path, line: usize) -> Self {
271 Self {
272 path: path.to_path_buf(),
273 kind: ScanErrorKind::Yaml { line: line.max(1) },
274 }
275 }
276}
277
278pub fn scan_dir(dir: &Path) -> Result<Vec<Workflow>, ScanError> {
283 let entries = fs::read_dir(dir).map_err(|_| ScanError::directory(dir))?;
284 let mut paths = Vec::new();
285 for entry in entries {
286 let entry = entry.map_err(|_| ScanError::directory(dir))?;
287 let path = entry.path();
288 if path.is_file() && is_workflow_file(&path) {
289 paths.push(path);
290 }
291 }
292 paths.sort();
293
294 let mut workflows = Vec::with_capacity(paths.len());
295 for path in paths {
296 let text = fs::read_to_string(&path).map_err(|_| ScanError::file(&path))?;
297 workflows.push(scan_yaml(&path, &text)?);
298 }
299 Ok(workflows)
300}
301
302pub fn scan_yaml(path: &Path, text: &str) -> Result<Workflow, ScanError> {
307 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
308 let events = collect_events(text).map_err(|line| ScanError::yaml(path, line))?;
309 let root = document_root(&events).map_err(|line| ScanError::yaml(path, line))?;
310 Ok(workflow_from(path, root.as_ref()))
311}
312
313fn is_workflow_file(path: &Path) -> bool {
314 path.extension()
315 .and_then(|ext| ext.to_str())
316 .is_some_and(|ext| ext.eq_ignore_ascii_case("yml") || ext.eq_ignore_ascii_case("yaml"))
317}
318
319const MAX_NODE_DEPTH: usize = 128;
320
321struct Collector {
322 events: Vec<(Event, Marker)>,
323}
324
325impl MarkedEventReceiver for Collector {
326 fn on_event(&mut self, ev: Event, mark: Marker) {
327 self.events.push((ev, mark));
328 }
329}
330
331fn collect_events(text: &str) -> Result<Vec<(Event, Marker)>, usize> {
332 let caught = std::panic::catch_unwind(AssertUnwindSafe(|| {
333 let mut collector = Collector { events: Vec::new() };
334 let mut parser = Parser::new_from_str(text);
335 match parser.load(&mut collector, true) {
336 Ok(()) => Ok(collector.events),
337 Err(err) => Err(err.marker().line().max(1)),
338 }
339 }));
340 match caught {
341 Ok(result) => result,
342 Err(_) => Err(1),
343 }
344}
345
346#[derive(Clone)]
347enum Node {
348 Scalar {
349 value: String,
350 line: usize,
351 },
352 Sequence {
353 line: usize,
355 items: Vec<Node>,
356 },
357 Mapping {
358 line: usize,
360 entries: Vec<Entry>,
361 duplicates: Vec<DuplicateKey>,
362 },
363}
364
365#[derive(Clone)]
366struct Entry {
367 key: String,
368 line: usize,
369 value: Node,
370}
371
372const MAX_EXPANDED_NODES: usize = 100_000;
377
378struct Cursor<'a> {
379 events: &'a [(Event, Marker)],
380 index: usize,
381 expanded: usize,
382}
383
384fn node_size(node: &Node) -> usize {
385 match node {
386 Node::Scalar { .. } => 1,
387 Node::Sequence { items, .. } => 1 + items.iter().map(node_size).sum::<usize>(),
388 Node::Mapping { entries, .. } => {
389 1 + entries
390 .iter()
391 .map(|entry| node_size(&entry.value))
392 .sum::<usize>()
393 }
394 }
395}
396
397fn document_root(events: &[(Event, Marker)]) -> Result<Option<Node>, usize> {
398 let mut cursor = Cursor {
399 events,
400 index: 0,
401 expanded: 0,
402 };
403 let mut chosen = None;
404 let mut document_seen = false;
405 while cursor.index < events.len() {
406 let is_document = matches!(events[cursor.index].0, Event::DocumentStart);
407 if !is_document {
408 cursor.index += 1;
409 continue;
410 }
411 let line = events[cursor.index].1.line().max(1);
412 if document_seen {
413 return Err(line);
414 }
415 document_seen = true;
416 cursor.index += 1;
417 if cursor.index >= events.len() {
418 break;
419 }
420 let boundary = matches!(
421 events[cursor.index].0,
422 Event::DocumentEnd | Event::StreamEnd
423 );
424 if boundary {
425 continue;
426 }
427 let mut anchors = HashMap::new();
428 let mut building = Vec::new();
429 let node = cursor.parse_node(&mut anchors, &mut building, 0)?;
430 if chosen.is_none() && matches!(node, Node::Mapping { .. }) {
431 chosen = Some(node);
432 }
433 }
434 Ok(chosen)
435}
436
437impl Cursor<'_> {
438 fn parse_node(
439 &mut self,
440 anchors: &mut HashMap<usize, Node>,
441 building: &mut Vec<usize>,
442 depth: usize,
443 ) -> Result<Node, usize> {
444 if self.index >= self.events.len() {
445 return Err(1);
446 }
447 let line = self.events[self.index].1.line().max(1);
448 if depth > MAX_NODE_DEPTH {
449 return Err(line);
450 }
451 let event = self.events[self.index].0.clone();
452 self.index += 1;
453 match event {
454 Event::Scalar(value, _, anchor, _) => {
455 self.charge(1, line)?;
456 let node = Node::Scalar { value, line };
457 self.store_anchor(anchors, anchor, &node);
458 Ok(node)
459 }
460 Event::SequenceStart(anchor, _) => {
461 self.parse_sequence(anchors, building, depth, anchor, line)
462 }
463 Event::MappingStart(anchor, _) => {
464 self.parse_mapping(anchors, building, depth, anchor, line)
465 }
466 Event::Alias(id) => {
467 if building.contains(&id) {
468 return Err(line);
469 }
470 let node = anchors.get(&id).ok_or(line)?;
471 self.charge(node_size(node), line)?;
472 Ok(node.clone())
473 }
474 _ => Err(line),
475 }
476 }
477
478 fn parse_sequence(
479 &mut self,
480 anchors: &mut HashMap<usize, Node>,
481 building: &mut Vec<usize>,
482 depth: usize,
483 anchor: usize,
484 line: usize,
485 ) -> Result<Node, usize> {
486 self.push_anchor(building, anchor, line)?;
487 let mut items = Vec::new();
488 loop {
489 if self.index >= self.events.len() {
490 return Err(line);
491 }
492 if matches!(self.events[self.index].0, Event::SequenceEnd) {
493 self.index += 1;
494 break;
495 }
496 items.push(self.parse_node(anchors, building, depth + 1)?);
497 }
498 self.charge(1, line)?;
499 let node = Node::Sequence { line, items };
500 self.finish_anchor(anchors, building, anchor, &node);
501 Ok(node)
502 }
503
504 fn parse_mapping(
505 &mut self,
506 anchors: &mut HashMap<usize, Node>,
507 building: &mut Vec<usize>,
508 depth: usize,
509 anchor: usize,
510 line: usize,
511 ) -> Result<Node, usize> {
512 self.push_anchor(building, anchor, line)?;
513 let mut raw = Vec::new();
514 loop {
515 if self.index >= self.events.len() {
516 return Err(line);
517 }
518 if matches!(self.events[self.index].0, Event::MappingEnd) {
519 self.index += 1;
520 break;
521 }
522 let key = self.parse_node(anchors, building, depth + 1)?;
523 let value = self.parse_node(anchors, building, depth + 1)?;
524 if let Node::Scalar {
525 value: key_text,
526 line: key_line,
527 } = key
528 {
529 raw.push(Entry {
530 key: key_text,
531 line: key_line,
532 value,
533 });
534 }
535 }
536 let (entries, duplicates) = resolve_merge(raw);
537 self.charge(1, line)?;
538 let node = Node::Mapping {
539 line,
540 entries,
541 duplicates,
542 };
543 self.finish_anchor(anchors, building, anchor, &node);
544 Ok(node)
545 }
546
547 fn charge(&mut self, amount: usize, line: usize) -> Result<(), usize> {
548 self.expanded = self.expanded.saturating_add(amount);
549 if self.expanded > MAX_EXPANDED_NODES {
550 Err(line)
551 } else {
552 Ok(())
553 }
554 }
555
556 fn push_anchor(
557 &self,
558 building: &mut Vec<usize>,
559 anchor: usize,
560 line: usize,
561 ) -> Result<(), usize> {
562 if anchor == 0 {
563 return Ok(());
564 }
565 if building.contains(&anchor) {
566 return Err(line);
567 }
568 building.push(anchor);
569 Ok(())
570 }
571
572 fn finish_anchor(
573 &self,
574 anchors: &mut HashMap<usize, Node>,
575 building: &mut Vec<usize>,
576 anchor: usize,
577 node: &Node,
578 ) {
579 self.store_anchor(anchors, anchor, node);
580 if anchor != 0 {
581 building.pop();
582 }
583 }
584
585 fn store_anchor(&self, anchors: &mut HashMap<usize, Node>, anchor: usize, node: &Node) {
586 if anchor != 0 {
587 anchors.insert(anchor, node.clone());
588 }
589 }
590}
591
592fn resolve_merge(entries: Vec<Entry>) -> (Vec<Entry>, Vec<DuplicateKey>) {
595 let mut explicit: Vec<Entry> = Vec::new();
596 let mut merge: Option<Node> = None;
597 let mut merge_line: Option<usize> = None;
598 let mut duplicates: Vec<DuplicateKey> = Vec::new();
599 for item in entries {
600 if item.key == "<<" {
601 if let Some(previous) = merge_line {
602 duplicates.push(DuplicateKey {
603 key: "<<".to_string(),
604 first: previous,
605 second: item.line,
606 });
607 }
608 merge_line = Some(item.line);
609 merge = Some(item.value);
610 continue;
611 }
612 if let Some(pos) = explicit.iter().position(|entry| entry.key == item.key) {
613 duplicates.push(DuplicateKey {
614 key: item.key.clone(),
615 first: explicit[pos].line,
616 second: item.line,
617 });
618 explicit[pos] = item;
619 } else {
620 explicit.push(item);
621 }
622 }
623 if let Some(value) = merge {
624 let mut seen: HashSet<String> = explicit.iter().map(|entry| entry.key.clone()).collect();
625 merge_node(&mut explicit, &mut seen, &value);
626 }
627 (explicit, duplicates)
628}
629
630fn collect_duplicates(node: &Node, out: &mut Vec<DuplicateKey>) {
631 match node {
632 Node::Mapping {
633 entries,
634 duplicates,
635 ..
636 } => {
637 out.extend(duplicates.iter().cloned());
638 for item in entries {
639 collect_duplicates(&item.value, out);
640 }
641 }
642 Node::Sequence { items, .. } => {
643 for item in items {
644 collect_duplicates(item, out);
645 }
646 }
647 Node::Scalar { .. } => {}
648 }
649}
650
651fn dedupe_duplicates(items: Vec<DuplicateKey>) -> Vec<DuplicateKey> {
652 let mut seen = HashSet::new();
653 let mut out = Vec::new();
654 for item in items {
655 if seen.insert((item.key.clone(), item.first, item.second)) {
656 out.push(item);
657 }
658 }
659 out
660}
661
662fn merge_node(out: &mut Vec<Entry>, seen: &mut HashSet<String>, node: &Node) {
663 match node {
664 Node::Mapping { entries, .. } => {
665 for item in entries {
666 if item.key == "<<" {
667 merge_node(out, seen, &item.value);
668 } else if seen.insert(item.key.clone()) {
669 out.push(item.clone());
670 }
671 }
672 }
673 Node::Sequence { items, .. } => {
674 for item in items {
675 merge_node(out, seen, item);
676 }
677 }
678 Node::Scalar { .. } => {}
679 }
680}
681
682fn entry<'a>(entries: &'a [Entry], key: &str) -> Option<&'a Entry> {
683 entries.iter().find(|item| item.key == key)
684}
685
686fn entry_last<'a>(entries: &'a [Entry], key: &str) -> Option<&'a Entry> {
688 entries
689 .iter()
690 .rev()
691 .find(|item| item.key.eq_ignore_ascii_case(key))
692}
693
694fn workflow_from(path: &Path, root: Option<&Node>) -> Workflow {
695 let mut duplicate_keys = Vec::new();
696 if let Some(node) = root {
697 collect_duplicates(node, &mut duplicate_keys);
698 }
699 let mut workflow = Workflow {
700 path: path.to_path_buf(),
701 on_line: None,
702 has_pull_request: false,
703 has_pull_request_target: false,
704 cancel_in_progress: false,
705 concurrency_line: None,
706 jobs: Vec::new(),
707 duplicate_keys: dedupe_duplicates(duplicate_keys),
708 };
709 let Some(Node::Mapping { entries, .. }) = root else {
710 return workflow;
711 };
712 for item in entries {
713 if item.key == "on" {
714 note_on(&mut workflow, item);
715 } else if item.key.eq_ignore_ascii_case("concurrency") {
716 note_concurrency(&mut workflow, item.line, &item.value);
717 } else if item.key == "jobs" {
718 note_jobs(&mut workflow, &item.value);
719 }
720 }
721 workflow
722}
723
724fn note_on(workflow: &mut Workflow, item: &Entry) {
725 if workflow.on_line.is_none() {
726 workflow.on_line = Some(item.line);
727 }
728 if has_event(&item.value, "pull_request") {
729 workflow.has_pull_request = true;
730 }
731 if has_event(&item.value, "pull_request_target") {
732 workflow.has_pull_request_target = true;
733 }
734}
735
736fn has_event(node: &Node, event: &str) -> bool {
737 match node {
738 Node::Scalar { value, .. } => value.eq_ignore_ascii_case(event),
739 Node::Sequence { items, .. } => items.iter().any(
740 |item| matches!(item, Node::Scalar { value, .. } if value.eq_ignore_ascii_case(event)),
741 ),
742 Node::Mapping { entries, .. } => entries
743 .iter()
744 .any(|item| item.key.eq_ignore_ascii_case(event)),
745 }
746}
747
748fn note_concurrency(workflow: &mut Workflow, line: usize, node: &Node) {
749 workflow.concurrency_line = Some(line);
750 workflow.cancel_in_progress = concurrency_enabled(node);
751}
752
753fn concurrency_enabled(node: &Node) -> bool {
754 let Node::Mapping { entries, .. } = node else {
755 return false;
756 };
757 entry(entries, "cancel-in-progress").is_some_and(|item| scalar_enables_cancel(&item.value))
758}
759
760fn scalar_enables_cancel(node: &Node) -> bool {
761 match node {
762 Node::Scalar { value, .. } => expression_enables_cancel(value),
763 _ => false,
764 }
765}
766
767fn expression_enables_cancel(value: &str) -> bool {
771 clause_enables_cancel(value.trim())
772}
773
774fn clause_enables_cancel(value: &str) -> bool {
775 let unwrapped = unwrap_expr(value.trim());
776 let text = strip_wrapping_parens(unwrapped.trim());
777 if text.eq_ignore_ascii_case("true") {
778 return true;
779 }
780 let parts = split_top_or(text);
781 if parts.len() > 1 {
782 return parts.iter().any(|part| clause_enables_cancel(part));
783 }
784 is_pull_event_comparison(text)
785}
786
787fn unwrap_expr(text: &str) -> &str {
788 let mut current = text.trim();
789 loop {
790 let Some(rest) = current.strip_prefix("${{") else {
791 return current;
792 };
793 let Some(inner) = rest.strip_suffix("}}") else {
794 return current;
795 };
796 if inner.contains("${{") || inner.contains("}}") {
797 return current;
798 }
799 current = inner.trim();
800 }
801}
802
803fn strip_wrapping_parens(text: &str) -> &str {
804 let mut current = text.trim();
805 while current.starts_with('(') && current.ends_with(')') && parens_wrap_all(current) {
806 current = current[1..current.len() - 1].trim();
807 }
808 current
809}
810
811fn parens_wrap_all(text: &str) -> bool {
812 let mut depth = 0i32;
813 for (index, character) in text.char_indices() {
814 match character {
815 '(' => depth += 1,
816 ')' => {
817 depth -= 1;
818 if depth < 0 {
819 return false;
820 }
821 if depth == 0 && index != text.len() - 1 {
822 return false;
823 }
824 }
825 _ => {}
826 }
827 }
828 depth == 0
829}
830
831fn split_top_or(text: &str) -> Vec<&str> {
832 let mut parts = Vec::new();
833 let mut start = 0;
834 let mut quote: Option<char> = None;
835 let chars: Vec<(usize, char)> = text.char_indices().collect();
836 let mut index = 0;
837 while index < chars.len() {
838 let (byte, character) = chars[index];
839 if let Some(open) = quote {
840 if character == open {
841 quote = None;
842 }
843 index += 1;
844 continue;
845 }
846 if character == '\'' || character == '"' {
847 quote = Some(character);
848 index += 1;
849 continue;
850 }
851 if character == '|' && chars.get(index + 1).is_some_and(|(_, next)| *next == '|') {
852 parts.push(&text[start..byte]);
853 start = chars[index + 1].0 + 1;
854 index += 2;
855 continue;
856 }
857 index += 1;
858 }
859 parts.push(&text[start..]);
860 parts
861}
862
863fn is_pull_event_comparison(text: &str) -> bool {
864 let Some(rest) = strip_prefix_ignore_ascii_case(text.trim(), "github.event_name") else {
865 return false;
866 };
867 if rest.starts_with(|character: char| character.is_ascii_alphanumeric() || character == '_') {
868 return false;
869 }
870 let rest = rest.trim_start();
871 let Some(rest) = rest.strip_prefix("==") else {
872 return false;
873 };
874 if rest.starts_with('=') {
875 return false;
876 }
877 let rest = rest.trim_start();
878 let Some((body, rest)) = quoted_literal(rest) else {
879 return false;
880 };
881 if !rest.trim().is_empty() {
882 return false;
883 }
884 body.eq_ignore_ascii_case("pull_request") || body.eq_ignore_ascii_case("pull_request_target")
885}
886
887fn strip_prefix_ignore_ascii_case<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
888 let mut rest = text;
889 for expected in prefix.chars() {
890 let mut chars = rest.chars();
891 let got = chars.next()?;
892 if !got.eq_ignore_ascii_case(&expected) {
893 return None;
894 }
895 rest = chars.as_str();
896 }
897 Some(rest)
898}
899
900fn quoted_literal(text: &str) -> Option<(&str, &str)> {
901 let quote = text.chars().next()?;
902 if quote != '\'' && quote != '"' {
903 return None;
904 }
905 let rest = &text[quote.len_utf8()..];
906 let end = rest.find(quote)?;
907 Some((&rest[..end], &rest[end + quote.len_utf8()..]))
908}
909
910fn note_jobs(workflow: &mut Workflow, node: &Node) {
911 let Node::Mapping { entries, .. } = node else {
912 return;
913 };
914 for item in entries {
915 if !is_job_name(&item.key) {
916 continue;
917 }
918 let mut job = Job {
919 name: item.key.clone(),
920 line: item.line,
921 display_name: None,
922 runs_on: None,
923 has_timeout_minutes: false,
924 steps_text: String::new(),
925 steps: Vec::new(),
926 matrix: None,
927 cancel_in_progress: false,
928 concurrency_line: None,
929 uses: None,
930 invalid_uses: None,
931 };
932 if let Node::Mapping { entries, .. } = &item.value {
933 fill_job(&mut job, entries);
934 }
935 workflow.jobs.push(job);
936 }
937}
938
939fn is_job_name(name: &str) -> bool {
940 let mut chars = name.chars();
941 match chars.next() {
942 Some(first) if first.is_ascii_alphabetic() || first == '_' => {}
943 _ => return false,
944 }
945 chars.all(|character| character.is_ascii_alphanumeric() || character == '_' || character == '-')
946}
947
948fn fill_job(job: &mut Job, entries: &[Entry]) {
949 if let Some(item) = entry(entries, "runs-on") {
950 job.runs_on = Some(runs_on_from(item.line, &item.value));
951 }
952 if entry(entries, "timeout-minutes").is_some() {
953 job.has_timeout_minutes = true;
954 }
955 if let Some(item) = entry(entries, "name") {
956 if let Some(text) = scalar_text(&item.value) {
957 let text = trim_breaks(text);
958 if !text.is_empty() {
959 job.display_name = Some(text);
960 }
961 }
962 }
963 if let Some(item) = entry_last(entries, "steps") {
964 job.steps_text = step_runs(&item.value);
965 job.steps = steps_in(&item.value);
966 }
967 if let Some(item) = entry(entries, "strategy") {
968 job.matrix = matrix_from(&item.value);
969 }
970 if let Some(item) = entries
971 .iter()
972 .rev()
973 .find(|item| item.key.eq_ignore_ascii_case("concurrency"))
974 {
975 job.concurrency_line = Some(item.line);
976 job.cancel_in_progress = concurrency_enabled(&item.value);
977 }
978 if let Some(item) = entry(entries, "uses") {
979 match classify_uses(item.line, &item.value) {
980 UsesClass::Call(call) => job.uses = Some(call),
981 UsesClass::Invalid(invalid) => job.invalid_uses = Some(invalid),
982 }
983 }
984}
985
986enum UsesClass {
987 Call(ReusableCall),
988 Invalid(InvalidUses),
989}
990
991fn classify_uses(line: usize, node: &Node) -> UsesClass {
992 match node {
993 Node::Scalar { value, .. } => classify_uses_scalar(line, value),
994 Node::Sequence { .. } => UsesClass::Invalid(InvalidUses {
995 line,
996 detail: "a list".to_string(),
997 }),
998 Node::Mapping { .. } => UsesClass::Invalid(InvalidUses {
999 line,
1000 detail: "a mapping".to_string(),
1001 }),
1002 }
1003}
1004
1005fn classify_uses_scalar(line: usize, raw: &str) -> UsesClass {
1006 let text = trim_breaks(raw);
1007 let text = text.trim();
1008 if text.is_empty() {
1009 return UsesClass::Invalid(InvalidUses {
1010 line,
1011 detail: "\"\"".to_string(),
1012 });
1013 }
1014 if let Some(call) = local_reusable_call(line, text) {
1015 return UsesClass::Call(call);
1016 }
1017 if let Some(call) = remote_reusable_call(line, text) {
1018 return UsesClass::Call(call);
1019 }
1020 UsesClass::Invalid(InvalidUses {
1021 line,
1022 detail: format!("{text:?}"),
1023 })
1024}
1025
1026fn local_reusable_call(line: usize, spec: &str) -> Option<ReusableCall> {
1029 if spec.is_empty() || spec.contains('@') || spec.contains("://") {
1030 return None;
1031 }
1032 let rest = spec.strip_prefix("./")?;
1033 let mut parts: Vec<&str> = Vec::new();
1034 for part in rest.split(['/', '\\']) {
1035 if part.is_empty() || part == "." {
1036 continue;
1037 }
1038 if part == ".." {
1039 parts.pop()?;
1040 continue;
1041 }
1042 parts.push(part);
1043 }
1044 if parts.len() != 3 {
1045 return None;
1046 }
1047 if !parts[0].eq_ignore_ascii_case(".github") || !parts[1].eq_ignore_ascii_case("workflows") {
1048 return None;
1049 }
1050 let file_name = parts[2];
1051 let ext = file_name.rsplit_once('.')?.1;
1052 if file_name.len() == ext.len() + 1 {
1053 return None;
1054 }
1055 if !ext.eq_ignore_ascii_case("yml") && !ext.eq_ignore_ascii_case("yaml") {
1056 return None;
1057 }
1058 Some(ReusableCall {
1059 line,
1060 spec: spec.to_string(),
1061 file_name: file_name.to_string(),
1062 source: ReusableSource::Local,
1063 })
1064}
1065
1066fn remote_reusable_call(line: usize, spec: &str) -> Option<ReusableCall> {
1071 if spec.contains("://")
1072 || spec
1073 .chars()
1074 .any(|character| character.is_whitespace() || character.is_control())
1075 {
1076 return None;
1077 }
1078 let (path, reference) = spec.split_once('@')?;
1079 if !valid_reference(reference) {
1080 return None;
1081 }
1082 let path = path.replace('\\', "/");
1083 let mut parts: Vec<&str> = Vec::new();
1084 for part in path.split('/') {
1085 if part.is_empty() || part == "." || part == ".." {
1086 return None;
1087 }
1088 parts.push(part);
1089 }
1090 if parts.len() != 5 {
1091 return None;
1092 }
1093 if !parts[2].eq_ignore_ascii_case(".github") || !parts[3].eq_ignore_ascii_case("workflows") {
1094 return None;
1095 }
1096 let file_name = parts[4];
1097 if !valid_github_name(parts[0])
1098 || !valid_github_name(parts[1])
1099 || !valid_workflow_file(file_name)
1100 {
1101 return None;
1102 }
1103 let workflow_path = format!("{}/{}/{}", parts[2], parts[3], file_name);
1104 Some(ReusableCall {
1105 line,
1106 spec: spec.to_string(),
1107 file_name: file_name.to_string(),
1108 source: ReusableSource::Remote(RemoteWorkflow {
1109 owner: parts[0].to_string(),
1110 repo: parts[1].to_string(),
1111 workflow_path,
1112 reference: reference.to_string(),
1113 }),
1114 })
1115}
1116
1117fn valid_github_name(name: &str) -> bool {
1118 let mut chars = name.chars();
1119 let Some(first) = chars.next() else {
1120 return false;
1121 };
1122 if !first.is_ascii_alphanumeric() {
1123 return false;
1124 }
1125 let mut last = first;
1126 for character in chars {
1127 if !(character.is_ascii_alphanumeric()
1128 || character == '.'
1129 || character == '_'
1130 || character == '-')
1131 {
1132 return false;
1133 }
1134 last = character;
1135 }
1136 last.is_ascii_alphanumeric()
1137}
1138
1139fn valid_workflow_file(name: &str) -> bool {
1140 let Some((stem, ext)) = name.rsplit_once('.') else {
1141 return false;
1142 };
1143 if stem.is_empty() || stem.starts_with('.') || stem.contains("..") {
1144 return false;
1145 }
1146 if !ext.eq_ignore_ascii_case("yml") && !ext.eq_ignore_ascii_case("yaml") {
1147 return false;
1148 }
1149 stem.chars().all(|character| {
1150 character.is_ascii_alphanumeric()
1151 || character == '.'
1152 || character == '_'
1153 || character == '-'
1154 })
1155}
1156
1157fn valid_reference(reference: &str) -> bool {
1158 if reference.is_empty()
1159 || reference.starts_with('/')
1160 || reference.ends_with('/')
1161 || reference.contains("//")
1162 || reference.contains('@')
1163 || reference.contains('\\')
1164 {
1165 return false;
1166 }
1167 if reference.chars().any(|character| {
1168 character.is_whitespace()
1169 || character.is_control()
1170 || matches!(
1171 character,
1172 '~' | '^' | ':' | '?' | '*' | '[' | ']' | '{' | '}'
1173 )
1174 }) {
1175 return false;
1176 }
1177 reference.split('/').all(valid_ref_segment)
1178}
1179
1180fn valid_ref_segment(segment: &str) -> bool {
1181 if segment.is_empty()
1182 || segment == "."
1183 || segment == ".."
1184 || segment.ends_with('.')
1185 || segment.ends_with(".lock")
1186 {
1187 return false;
1188 }
1189 segment.chars().all(|character| {
1190 character.is_ascii_alphanumeric()
1191 || character == '.'
1192 || character == '_'
1193 || character == '-'
1194 })
1195}
1196
1197fn runs_on_from(line: usize, node: &Node) -> RunsOn {
1198 match node {
1199 Node::Scalar { value, .. } => {
1200 let labels = if value.is_empty() {
1201 Vec::new()
1202 } else {
1203 vec![value.clone()]
1204 };
1205 RunsOn {
1206 line,
1207 value: value.clone(),
1208 labels,
1209 }
1210 }
1211 Node::Sequence { items, .. } => {
1212 let labels = scalar_list(items);
1213 RunsOn {
1214 line,
1215 value: bracket_list(&labels),
1216 labels,
1217 }
1218 }
1219 Node::Mapping { entries, .. } => {
1220 let labels = labels_of(entries);
1221 RunsOn {
1222 line,
1223 value: mapping_runs_on_value(entries, &labels),
1224 labels,
1225 }
1226 }
1227 }
1228}
1229
1230fn scalar_list(items: &[Node]) -> Vec<String> {
1231 items
1232 .iter()
1233 .filter_map(|item| match item {
1234 Node::Scalar { value, .. } if !value.is_empty() => Some(value.clone()),
1235 _ => None,
1236 })
1237 .collect()
1238}
1239
1240fn labels_of(entries: &[Entry]) -> Vec<String> {
1241 let Some(item) = entry(entries, "labels") else {
1242 return Vec::new();
1243 };
1244 match &item.value {
1245 Node::Scalar { value, .. } if !value.is_empty() => vec![value.clone()],
1246 Node::Sequence { items, .. } => scalar_list(items),
1247 _ => Vec::new(),
1248 }
1249}
1250
1251fn mapping_runs_on_value(entries: &[Entry], labels: &[String]) -> String {
1252 let group = entry(entries, "group").and_then(|item| match &item.value {
1253 Node::Scalar { value, .. } if !value.is_empty() => Some(value.as_str()),
1254 _ => None,
1255 });
1256 let labels_are_list =
1257 entry(entries, "labels").is_some_and(|item| matches!(item.value, Node::Sequence { .. }));
1258 let labels_text = if labels.is_empty() {
1259 None
1260 } else if labels_are_list || labels.len() != 1 {
1261 Some(bracket_list(labels))
1262 } else {
1263 Some(labels[0].clone())
1264 };
1265 match (group, labels_text) {
1266 (Some(group), Some(labels)) => format!("{{group: {group}, labels: {labels}}}"),
1267 (Some(group), None) => format!("{{group: {group}}}"),
1268 (None, Some(labels)) => labels,
1269 (None, None) => "{}".to_string(),
1270 }
1271}
1272
1273fn bracket_list(labels: &[String]) -> String {
1274 format!("[{}]", labels.join(", "))
1275}
1276
1277fn step_runs(node: &Node) -> String {
1278 let Node::Sequence { items, .. } = node else {
1279 return String::new();
1280 };
1281 let mut runs = Vec::new();
1282 for item in items {
1283 let Node::Mapping { entries, .. } = item else {
1284 continue;
1285 };
1286 let Some(run) = entry_last(entries, "run") else {
1287 continue;
1288 };
1289 let Some(text) = scalar_text(&run.value) else {
1290 continue;
1291 };
1292 let text = trim_breaks(text);
1293 if !text.is_empty() {
1294 runs.push(text);
1295 }
1296 }
1297 runs.join("\n")
1298}
1299
1300fn steps_in(node: &Node) -> Vec<Step> {
1301 match node {
1302 Node::Scalar { value, .. } if scalar_is_empty(value) => Vec::new(),
1303 other => {
1304 let mut steps = Vec::new();
1305 collect_steps(other, &mut steps, 0);
1306 steps
1307 }
1308 }
1309}
1310
1311fn scalar_is_empty(value: &str) -> bool {
1312 let value = value.trim();
1313 value.is_empty() || value == "~" || value.eq_ignore_ascii_case("null")
1314}
1315
1316fn collect_steps(node: &Node, out: &mut Vec<Step>, depth: usize) {
1317 if depth > MAX_NODE_DEPTH {
1318 out.push(Step {
1319 name: String::new(),
1320 line: node_line(node),
1321 local_action: None,
1322 });
1323 return;
1324 }
1325 match node {
1326 Node::Sequence { items, .. } => {
1327 for item in items {
1328 collect_steps(item, out, depth + 1);
1329 }
1330 }
1331 Node::Mapping { entries, line, .. } => out.push(step_from_entries(entries, *line)),
1332 Node::Scalar { value, line } => {
1333 let name = if scalar_is_empty(value) {
1334 String::new()
1335 } else {
1336 first_content_line(value)
1337 };
1338 out.push(Step {
1339 name,
1340 line: *line,
1341 local_action: None,
1342 });
1343 }
1344 }
1345}
1346
1347fn step_from_entries(entries: &[Entry], line: usize) -> Step {
1348 let local_action = local_action_of(entries);
1349 let mut step = if let Some(step) = step_from_key(entries, "name") {
1350 step
1351 } else if let Some(step) = step_from_key(entries, "uses") {
1352 step
1353 } else if let Some(step) = step_from_key(entries, "run") {
1354 step
1355 } else {
1356 Step {
1357 name: String::new(),
1358 line: entries.first().map_or(line, |item| item.line),
1359 local_action: None,
1360 }
1361 };
1362 step.local_action = local_action;
1363 step
1364}
1365
1366fn step_from_key(entries: &[Entry], key: &str) -> Option<Step> {
1367 let item = entry_last(entries, key)?;
1368 let text = scalar_text(&item.value)?;
1369 if scalar_is_empty(text) {
1370 return None;
1371 }
1372 let name = first_content_line(text);
1373 if name.is_empty() {
1374 return None;
1375 }
1376 Some(Step {
1377 name,
1378 line: item.line,
1379 local_action: None,
1380 })
1381}
1382
1383fn local_action_of(entries: &[Entry]) -> Option<LocalAction> {
1384 let item = entry_last(entries, "uses")?;
1385 let text = scalar_text(&item.value)?;
1386 local_action_spec(item.line, text)
1387}
1388
1389fn local_action_spec(line: usize, raw: &str) -> Option<LocalAction> {
1391 let spec = trim_breaks(raw);
1392 let spec = spec.trim();
1393 if spec.is_empty()
1394 || spec.contains('@')
1395 || spec.contains("://")
1396 || spec.chars().any(|character| character.is_control())
1397 {
1398 return None;
1399 }
1400 if local_reusable_call(line, spec).is_some() {
1401 return None;
1402 }
1403 let relative = normalize_action_dir(spec)?;
1404 if is_workflow_relative(&relative) {
1405 return None;
1406 }
1407 Some(LocalAction {
1408 line,
1409 spec: spec.to_string(),
1410 relative,
1411 })
1412}
1413
1414fn normalize_action_dir(spec: &str) -> Option<String> {
1419 let rest = spec
1420 .strip_prefix("./")
1421 .or_else(|| spec.strip_prefix(".\\"))?;
1422 let mut parts: Vec<&str> = Vec::new();
1423 for part in rest.split(['/', '\\']) {
1424 if part.is_empty() || part == "." {
1425 continue;
1426 }
1427 if part == ".." {
1428 parts.pop()?;
1429 continue;
1430 }
1431 parts.push(part);
1432 }
1433 if parts.last().is_some_and(|part| is_action_file_name(part)) {
1434 parts.pop();
1435 }
1436 Some(parts.join("/"))
1437}
1438
1439fn is_action_file_name(name: &str) -> bool {
1440 name.eq_ignore_ascii_case("action.yml") || name.eq_ignore_ascii_case("action.yaml")
1441}
1442
1443fn is_workflow_relative(relative: &str) -> bool {
1445 let mut parts = relative.split('/');
1446 let Some(github) = parts.next() else {
1447 return false;
1448 };
1449 let Some(workflows) = parts.next() else {
1450 return false;
1451 };
1452 let Some(file) = parts.next() else {
1453 return false;
1454 };
1455 if parts.next().is_some() {
1456 return false;
1457 }
1458 github.eq_ignore_ascii_case(".github")
1459 && workflows.eq_ignore_ascii_case("workflows")
1460 && valid_workflow_file(file)
1461}
1462
1463pub fn repository_root(workflow_file: &Path) -> Option<PathBuf> {
1465 let workflows = workflow_file.parent()?;
1466 if !file_name_eq(workflows, "workflows") {
1467 return None;
1468 }
1469 let github = workflows.parent()?;
1470 if !file_name_eq(github, ".github") {
1471 return None;
1472 }
1473 github.parent().map(Path::to_path_buf)
1474}
1475
1476fn file_name_eq(path: &Path, expected: &str) -> bool {
1477 path.file_name()
1478 .and_then(|name| name.to_str())
1479 .is_some_and(|name| name.eq_ignore_ascii_case(expected))
1480}
1481
1482pub fn locate_action(root: &Path, relative: &str) -> Result<Option<PathBuf>, ScanError> {
1488 let dir = join_relative(root, relative);
1489 if dir.is_file() && is_action_file_name_os(dir.file_name()) {
1490 return Ok(Some(dir));
1491 }
1492 if !dir.is_dir() {
1493 return Ok(None);
1494 }
1495 let entries = fs::read_dir(&dir).map_err(|_| ScanError::directory(&dir))?;
1496 let mut exact_yml = None;
1497 let mut exact_yaml = None;
1498 let mut folded_yml = None;
1499 let mut folded_yaml = None;
1500 for entry in entries {
1501 let entry = entry.map_err(|_| ScanError::directory(&dir))?;
1502 let path = entry.path();
1503 if !path.is_file() {
1504 continue;
1505 }
1506 let name = entry.file_name();
1507 let Some(text) = name.to_str() else {
1508 continue;
1509 };
1510 if text == "action.yml" {
1511 exact_yml = Some(path);
1512 } else if text == "action.yaml" {
1513 exact_yaml = Some(path);
1514 } else if folded_yml.is_none() && text.eq_ignore_ascii_case("action.yml") {
1515 folded_yml = Some(path);
1516 } else if folded_yaml.is_none() && text.eq_ignore_ascii_case("action.yaml") {
1517 folded_yaml = Some(path);
1518 }
1519 }
1520 Ok(exact_yml.or(exact_yaml).or(folded_yml).or(folded_yaml))
1521}
1522
1523fn is_action_file_name_os(name: Option<&std::ffi::OsStr>) -> bool {
1524 name.and_then(|name| name.to_str())
1525 .is_some_and(is_action_file_name)
1526}
1527
1528fn join_relative(root: &Path, relative: &str) -> PathBuf {
1529 let mut path = root.to_path_buf();
1530 for part in relative.split('/') {
1531 if !part.is_empty() && part != "." && part != ".." {
1532 path.push(part);
1533 }
1534 }
1535 path
1536}
1537
1538pub fn load_action(path: &Path) -> Result<ParsedAction, ScanError> {
1540 let text = fs::read_to_string(path).map_err(|_| ScanError::file(path))?;
1541 parse_action(path, &text)
1542}
1543
1544pub fn parse_action(path: &Path, text: &str) -> Result<ParsedAction, ScanError> {
1546 let text = text.strip_prefix('\u{feff}').unwrap_or(text);
1547 let events = collect_events(text).map_err(|line| ScanError::yaml(path, line))?;
1548 let root = document_root(&events).map_err(|line| ScanError::yaml(path, line))?;
1549 Ok(action_from(path, root.as_ref()))
1550}
1551
1552fn action_from(path: &Path, root: Option<&Node>) -> ParsedAction {
1553 let empty = ParsedAction {
1554 path: path.to_path_buf(),
1555 using: String::new(),
1556 steps: Vec::new(),
1557 };
1558 let Some(Node::Mapping { entries, .. }) = root else {
1559 return empty;
1560 };
1561 let Some(runs) = entry_last(entries, "runs") else {
1562 return empty;
1563 };
1564 let Node::Mapping { entries, .. } = &runs.value else {
1565 return ParsedAction {
1566 path: path.to_path_buf(),
1567 using: String::new(),
1568 steps: Vec::new(),
1569 };
1570 };
1571 let using = entry_last(entries, "using")
1572 .and_then(|item| scalar_text(&item.value))
1573 .map(|text| trim_breaks(text).trim().to_string())
1574 .unwrap_or_default();
1575 let steps = entry_last(entries, "steps")
1576 .map(|item| action_steps(&item.value))
1577 .unwrap_or_default();
1578 ParsedAction {
1579 path: path.to_path_buf(),
1580 using,
1581 steps,
1582 }
1583}
1584
1585fn action_steps(node: &Node) -> Vec<ActionStep> {
1586 match node {
1587 Node::Sequence { items, .. } => items
1588 .iter()
1589 .filter_map(|item| match item {
1590 Node::Mapping { entries, .. } => Some(action_step_from(entries)),
1591 _ => None,
1592 })
1593 .collect(),
1594 Node::Mapping { entries, .. } => vec![action_step_from(entries)],
1595 _ => Vec::new(),
1596 }
1597}
1598
1599fn action_step_from(entries: &[Entry]) -> ActionStep {
1600 let name = entry_last(entries, "name").and_then(|item| {
1601 let text = scalar_text(&item.value)?;
1602 let text = first_content_line(text);
1603 if text.is_empty() || scalar_is_empty(&text) {
1604 None
1605 } else {
1606 Some((text, item.line))
1607 }
1608 });
1609 let run = entry_last(entries, "run").and_then(|item| {
1610 let text = scalar_text(&item.value)?;
1611 let text = trim_breaks(text);
1612 if text.trim().is_empty() {
1613 None
1614 } else {
1615 Some((text, item.line))
1616 }
1617 });
1618 let (name, name_line) = match name {
1619 Some((text, line)) => (text, Some(line)),
1620 None => (String::new(), None),
1621 };
1622 let (run_text, run_line) = match run {
1623 Some((text, line)) => (text, Some(line)),
1624 None => (String::new(), None),
1625 };
1626 ActionStep {
1627 name,
1628 name_line,
1629 run_text,
1630 run_line,
1631 local_action: local_action_of(entries),
1632 }
1633}
1634
1635fn first_content_line(text: &str) -> String {
1636 text.lines()
1637 .map(str::trim)
1638 .find(|line| !line.is_empty())
1639 .unwrap_or("")
1640 .to_string()
1641}
1642
1643fn node_line(node: &Node) -> usize {
1644 match node {
1645 Node::Scalar { line, .. } | Node::Sequence { line, .. } | Node::Mapping { line, .. } => {
1646 *line
1647 }
1648 }
1649}
1650
1651fn scalar_text(node: &Node) -> Option<&str> {
1652 match node {
1653 Node::Scalar { value, .. } => Some(value.as_str()),
1654 _ => None,
1655 }
1656}
1657
1658fn trim_breaks(text: &str) -> String {
1659 text.trim_end_matches(['\n', '\r']).to_string()
1660}
1661
1662const MAX_MATRIX_COMBINATIONS: u64 = 200_000;
1665
1666struct AxisSpec {
1667 name: String,
1668 values: Vec<String>,
1669 dynamic: bool,
1670}
1671
1672struct Table {
1673 rows: Vec<Row>,
1674 dynamic: bool,
1676}
1677
1678type Row = Vec<(String, String)>;
1679
1680fn matrix_from(strategy: &Node) -> Option<Matrix> {
1681 let Node::Mapping { entries, .. } = strategy else {
1682 return None;
1683 };
1684 let matrix = entry(entries, "matrix")?;
1685 Some(eval_matrix(matrix.line, &matrix.value))
1686}
1687
1688fn eval_matrix(line: usize, node: &Node) -> Matrix {
1689 match node {
1690 Node::Scalar { value, .. } if is_github_expression(value) => Matrix {
1691 line,
1692 jobs: 1,
1693 at_least: true,
1694 jobs_hi: None,
1695 axes: Vec::new(),
1696 },
1697 Node::Mapping { entries, .. } => count_matrix(line, entries),
1698 _ => Matrix {
1699 line,
1700 jobs: 0,
1701 at_least: false,
1702 jobs_hi: None,
1703 axes: Vec::new(),
1704 },
1705 }
1706}
1707
1708fn count_matrix(line: usize, entries: &[Entry]) -> Matrix {
1709 let mut axes = Vec::new();
1710 let mut include = Table {
1711 rows: Vec::new(),
1712 dynamic: false,
1713 };
1714 let mut exclude = Table {
1715 rows: Vec::new(),
1716 dynamic: false,
1717 };
1718 for item in entries {
1719 match item.key.as_str() {
1720 "include" => include = parse_table(&item.value),
1721 "exclude" => exclude = parse_table(&item.value),
1722 _ => axes.push(parse_axis(&item.key, &item.value)),
1723 }
1724 }
1725 let (jobs, jobs_hi, at_least) = combination_count(&axes, &include, &exclude);
1726 Matrix {
1727 line,
1728 jobs,
1729 at_least,
1730 jobs_hi,
1731 axes: runner_axes(entries),
1732 }
1733}
1734
1735fn runner_axes(entries: &[Entry]) -> Vec<MatrixAxis> {
1736 let mut axes = Vec::new();
1737 for item in entries {
1738 match item.key.as_str() {
1739 "include" => note_include_runners(&item.value, &mut axes),
1740 "exclude" => {}
1741 _ => note_axis_definition(item.line, &item.key, &item.value, &mut axes),
1742 }
1743 }
1744 axes
1745}
1746
1747fn note_axis_definition(line: usize, name: &str, node: &Node, axes: &mut Vec<MatrixAxis>) {
1748 note_axis(axes, name, line, true, axis_label_sets(node));
1749}
1750
1751fn note_include_runners(node: &Node, axes: &mut Vec<MatrixAxis>) {
1752 match node {
1753 Node::Sequence { items, .. } => {
1754 for item in items {
1755 note_include_row(item, axes);
1756 }
1757 }
1758 Node::Mapping { .. } => note_include_row(node, axes),
1759 _ => {}
1760 }
1761}
1762
1763fn note_include_row(node: &Node, axes: &mut Vec<MatrixAxis>) {
1764 let Node::Mapping { entries, .. } = node else {
1765 return;
1766 };
1767 for item in entries {
1768 if let Some(labels) = runner_label_set(&item.value) {
1769 note_axis(axes, &item.key, item.line, false, vec![labels]);
1770 }
1771 }
1772}
1773
1774fn note_axis(
1775 axes: &mut Vec<MatrixAxis>,
1776 name: &str,
1777 line: usize,
1778 is_definition: bool,
1779 sets: Vec<Vec<String>>,
1780) {
1781 if let Some(axis) = axes.iter_mut().find(|axis| axis.name == name) {
1782 if is_definition {
1783 axis.line = line;
1784 }
1785 axis.values.extend(sets);
1786 return;
1787 }
1788 if !is_definition && sets.is_empty() {
1789 return;
1790 }
1791 axes.push(MatrixAxis {
1792 name: name.to_string(),
1793 line,
1794 values: sets,
1795 });
1796}
1797
1798fn axis_label_sets(node: &Node) -> Vec<Vec<String>> {
1800 match node {
1801 Node::Sequence { items, .. } => items.iter().filter_map(runner_label_set).collect(),
1802 Node::Scalar { value, .. } if value.is_empty() || is_github_expression(value) => Vec::new(),
1803 other => runner_label_set(other).into_iter().collect(),
1804 }
1805}
1806
1807fn runner_label_set(node: &Node) -> Option<Vec<String>> {
1808 let labels = match node {
1809 Node::Scalar { value, .. } => {
1810 if value.is_empty() || is_github_expression(value) {
1811 return None;
1812 }
1813 vec![value.clone()]
1814 }
1815 Node::Sequence { items, .. } => items
1816 .iter()
1817 .filter_map(|item| match item {
1818 Node::Scalar { value, .. } if !value.is_empty() && !is_github_expression(value) => {
1819 Some(value.clone())
1820 }
1821 _ => None,
1822 })
1823 .collect(),
1824 Node::Mapping { entries, .. } => labels_of(entries),
1825 };
1826 if labels.is_empty() {
1827 None
1828 } else {
1829 Some(labels)
1830 }
1831}
1832
1833fn parse_axis(name: &str, node: &Node) -> AxisSpec {
1834 match node {
1835 Node::Sequence { items, .. } => AxisSpec {
1836 name: name.to_string(),
1837 values: items.iter().map(canonical).collect(),
1838 dynamic: false,
1839 },
1840 Node::Scalar { value, .. } if is_github_expression(value) => AxisSpec {
1841 name: name.to_string(),
1842 values: Vec::new(),
1843 dynamic: true,
1844 },
1845 other => AxisSpec {
1846 name: name.to_string(),
1847 values: vec![canonical(other)],
1848 dynamic: false,
1849 },
1850 }
1851}
1852
1853fn parse_table(node: &Node) -> Table {
1854 match node {
1855 Node::Scalar { value, .. } if is_github_expression(value) => Table {
1856 rows: Vec::new(),
1857 dynamic: true,
1858 },
1859 Node::Sequence { items, .. } => {
1860 let dynamic = items.iter().any(
1861 |item| matches!(item, Node::Scalar { value, .. } if is_github_expression(value)),
1862 );
1863 let mut rows = Vec::new();
1864 for item in items {
1865 if let Node::Mapping { entries, .. } = item {
1866 rows.push(row_from(entries));
1867 }
1868 }
1869 Table { rows, dynamic }
1870 }
1871 Node::Mapping { entries, .. } => Table {
1872 rows: vec![row_from(entries)],
1873 dynamic: false,
1874 },
1875 _ => Table {
1876 rows: Vec::new(),
1877 dynamic: false,
1878 },
1879 }
1880}
1881
1882fn row_from(entries: &[Entry]) -> Vec<(String, String)> {
1883 entries
1884 .iter()
1885 .map(|item| (item.key.clone(), canonical(&item.value)))
1886 .collect()
1887}
1888
1889fn is_github_expression(value: &str) -> bool {
1891 let trimmed = value.trim();
1892 let Some(rest) = trimmed.strip_prefix("${{") else {
1893 return false;
1894 };
1895 let Some(inner) = rest.strip_suffix("}}") else {
1896 return false;
1897 };
1898 !inner.contains("${{") && !inner.contains("}}")
1899}
1900
1901fn canonical(node: &Node) -> String {
1902 match node {
1903 Node::Scalar { value, .. } => value.clone(),
1904 Node::Sequence { items, .. } => {
1905 let parts: Vec<_> = items.iter().map(canonical).collect();
1906 format!("[{}]", parts.join(","))
1907 }
1908 Node::Mapping { entries, .. } => {
1909 let mut parts: Vec<_> = entries
1910 .iter()
1911 .map(|item| format!("{}={}", item.key, canonical(&item.value)))
1912 .collect();
1913 parts.sort();
1914 format!("{{{}}}", parts.join(","))
1915 }
1916 }
1917}
1918
1919fn combination_count(
1921 axes: &[AxisSpec],
1922 include: &Table,
1923 exclude: &Table,
1924) -> (u64, Option<u64>, bool) {
1925 let dynamic_axis = axes.iter().any(|axis| axis.dynamic);
1926 let concrete: Vec<&AxisSpec> = axes.iter().filter(|axis| !axis.dynamic).collect();
1927 let empty_axis = concrete.iter().any(|axis| axis.values.is_empty());
1928 let axis_names: HashSet<&str> = concrete.iter().map(|axis| axis.name.as_str()).collect();
1929 let dynamic_names: HashSet<&str> = axes
1930 .iter()
1931 .filter(|axis| axis.dynamic)
1932 .map(|axis| axis.name.as_str())
1933 .collect();
1934 let dynamic_only = dynamic_axis && concrete.is_empty() && !empty_axis;
1935 let unbounded = (dynamic_axis && !empty_axis) || include.dynamic;
1936
1937 let (product, saturated) = axis_product(&concrete);
1938 let over_cap = saturated || product > MAX_MATRIX_COMBINATIONS;
1939 let original = if empty_axis || concrete.is_empty() || over_cap {
1940 Vec::new()
1941 } else {
1942 cartesian(&concrete)
1943 };
1944
1945 if unbounded {
1946 let jobs = if dynamic_only && !exclude.dynamic {
1947 1
1948 } else if over_cap || empty_axis || concrete.is_empty() {
1949 let new = if dynamic_only {
1950 0
1951 } else {
1952 count_new_includes_analytical(&concrete, &include.rows, &axis_names)
1953 };
1954 if exclude.dynamic || empty_axis || concrete.is_empty() {
1955 new
1956 } else {
1957 product.saturating_add(new)
1958 }
1959 } else if exclude.dynamic {
1960 count_new_includes(&original, &include.rows, &axis_names)
1961 } else {
1962 let reduced = apply_excludes(&original, &exclude.rows, &dynamic_names);
1963 u64::try_from(reduced.len())
1964 .unwrap_or(u64::MAX)
1965 .saturating_add(count_new_includes(&reduced, &include.rows, &axis_names))
1966 };
1967 return (jobs, None, true);
1968 }
1969
1970 if over_cap {
1971 return capped_bounds(&concrete, include, exclude, product, &axis_names);
1972 }
1973
1974 let reduced = apply_excludes(&original, &exclude.rows, &dynamic_names);
1975 if exclude.dynamic {
1976 let lo = count_new_includes(&original, &include.rows, &axis_names);
1977 let hi = u64::try_from(reduced.len())
1978 .unwrap_or(u64::MAX)
1979 .saturating_add(count_new_includes(&reduced, &include.rows, &axis_names));
1980 return range(lo.min(hi), hi);
1981 }
1982 let jobs = u64::try_from(reduced.len())
1983 .unwrap_or(u64::MAX)
1984 .saturating_add(count_new_includes(&reduced, &include.rows, &axis_names));
1985 (jobs, None, false)
1986}
1987
1988fn capped_bounds(
1991 concrete: &[&AxisSpec],
1992 include: &Table,
1993 exclude: &Table,
1994 product: u64,
1995 axis_names: &HashSet<&str>,
1996) -> (u64, Option<u64>, bool) {
1997 let (min_remove, max_remove) =
1998 exclusion_bounds(&exclude.rows, concrete, product, exclude.dynamic);
1999 let new = count_new_includes_analytical(concrete, &include.rows, axis_names);
2000 let matching = include
2001 .rows
2002 .iter()
2003 .filter(|row| matches_original_space(row, concrete))
2004 .count();
2005 let matching = u64::try_from(matching).unwrap_or(u64::MAX);
2006 let restore = if max_remove == 0 { 0 } else { matching };
2009 let hi = product
2010 .saturating_sub(min_remove)
2011 .saturating_add(new)
2012 .saturating_add(restore);
2013 let lo = product.saturating_sub(max_remove).saturating_add(new);
2014 range(lo.min(hi), hi)
2015}
2016
2017fn range(lo: u64, hi: u64) -> (u64, Option<u64>, bool) {
2018 if hi > lo {
2019 (lo, Some(hi), false)
2020 } else {
2021 (lo, None, false)
2022 }
2023}
2024
2025fn apply_excludes(original: &[Row], rows: &[Row], dynamic_names: &HashSet<&str>) -> Vec<Row> {
2026 if rows.is_empty() {
2027 return original.to_vec();
2028 }
2029 let mut kept = original.to_vec();
2030 kept.retain(|combo| {
2031 !rows
2032 .iter()
2033 .any(|row| exclude_hits(combo, row, dynamic_names))
2034 });
2035 kept
2036}
2037
2038fn exclusion_bounds(rows: &[Row], axes: &[&AxisSpec], product: u64, dynamic: bool) -> (u64, u64) {
2039 if product == 0 {
2040 return (0, 0);
2041 }
2042 let mut min_remove = 0u64;
2043 let mut max_remove = 0u64;
2044 for row in rows {
2045 let span = exclusion_span(row, axes, product);
2046 min_remove = min_remove.max(span);
2047 max_remove = max_remove.saturating_add(span).min(product);
2048 }
2049 if dynamic {
2050 max_remove = product;
2051 }
2052 (min_remove.min(product), max_remove.min(product))
2053}
2054
2055fn exclusion_span(row: &Row, axes: &[&AxisSpec], product: u64) -> u64 {
2061 if product == 0 || axes.is_empty() {
2062 return 0;
2063 }
2064 if row.is_empty() {
2065 return product;
2066 }
2067 let mut specified = 1u64;
2068 for (key, value) in row {
2069 let Some(axis) = axes.iter().find(|axis| axis.name == *key) else {
2070 return 0;
2071 };
2072 if !axis.values.iter().any(|item| item == value) {
2073 return 0;
2074 }
2075 let len = u64::try_from(axis.values.len()).unwrap_or(u64::MAX);
2076 if len == 0 || specified > u64::MAX / len {
2077 return product;
2078 }
2079 specified *= len;
2080 }
2081 if specified == 0 || specified > product {
2082 return 0;
2083 }
2084 product / specified
2085}
2086
2087fn axis_product(axes: &[&AxisSpec]) -> (u64, bool) {
2088 if axes.is_empty() {
2089 return (0, false);
2090 }
2091 let mut total = 1u64;
2092 let mut saturated = false;
2093 for axis in axes {
2094 if axis.values.is_empty() {
2095 return (0, false);
2096 }
2097 let len = u64::try_from(axis.values.len()).unwrap_or(u64::MAX);
2098 if len != 0 && total > u64::MAX / len {
2099 saturated = true;
2100 total = u64::MAX;
2101 } else {
2102 total *= len;
2103 }
2104 }
2105 (total, saturated)
2106}
2107
2108fn cartesian(axes: &[&AxisSpec]) -> Vec<Vec<(String, String)>> {
2109 let mut combos = vec![Vec::new()];
2110 for axis in axes {
2111 let mut next = Vec::with_capacity(combos.len().saturating_mul(axis.values.len()));
2112 for combo in &combos {
2113 for value in &axis.values {
2114 let mut fields = Vec::with_capacity(combo.len() + 1);
2115 fields.extend(combo.iter().cloned());
2116 fields.push((axis.name.clone(), value.clone()));
2117 next.push(fields);
2118 }
2119 }
2120 combos = next;
2121 }
2122 combos
2123}
2124
2125fn exclude_hits(
2126 combo: &[(String, String)],
2127 row: &[(String, String)],
2128 dynamic_names: &HashSet<&str>,
2129) -> bool {
2130 row.iter().all(|pair| {
2133 dynamic_names.contains(pair.0.as_str())
2134 || combo
2135 .iter()
2136 .any(|field| field.0 == pair.0 && field.1 == pair.1)
2137 })
2138}
2139
2140fn include_expands(
2141 combo: &[(String, String)],
2142 row: &[(String, String)],
2143 axis_names: &HashSet<&str>,
2144) -> bool {
2145 row.iter()
2146 .filter(|pair| axis_names.contains(pair.0.as_str()))
2147 .all(|pair| {
2148 combo
2149 .iter()
2150 .any(|field| field.0 == pair.0 && field.1 == pair.1)
2151 })
2152}
2153
2154fn count_new_includes(base: &[Row], rows: &[Row], axis_names: &HashSet<&str>) -> u64 {
2161 let mut extras: Vec<Row> = Vec::new();
2162 for row in rows {
2163 if base
2164 .iter()
2165 .any(|combo| include_expands(combo, row, axis_names))
2166 {
2167 continue;
2168 }
2169 if extras
2170 .iter()
2171 .any(|combo| extra_accepts(combo, row, axis_names))
2172 {
2173 for combo in &mut extras {
2174 if extra_accepts(combo, row, axis_names) {
2175 overlay(combo, row);
2176 }
2177 }
2178 continue;
2179 }
2180 extras.push(row.clone());
2181 }
2182 u64::try_from(extras.len()).unwrap_or(u64::MAX)
2183}
2184
2185fn count_new_includes_analytical(
2186 axes: &[&AxisSpec],
2187 rows: &[Row],
2188 axis_names: &HashSet<&str>,
2189) -> u64 {
2190 let mut extras: Vec<Row> = Vec::new();
2191 for row in rows {
2192 if matches_original_space(row, axes) {
2193 continue;
2194 }
2195 if extras
2196 .iter()
2197 .any(|combo| extra_accepts(combo, row, axis_names))
2198 {
2199 for combo in &mut extras {
2200 if extra_accepts(combo, row, axis_names) {
2201 overlay(combo, row);
2202 }
2203 }
2204 continue;
2205 }
2206 extras.push(row.clone());
2207 }
2208 u64::try_from(extras.len()).unwrap_or(u64::MAX)
2209}
2210
2211fn matches_original_space(row: &Row, axes: &[&AxisSpec]) -> bool {
2212 if axes.is_empty() || axes.iter().any(|axis| axis.values.is_empty()) {
2213 return false;
2214 }
2215 for (key, value) in row {
2216 if let Some(axis) = axes.iter().find(|axis| axis.name == *key) {
2217 if !axis.values.iter().any(|item| item == value) {
2218 return false;
2219 }
2220 }
2221 }
2222 true
2223}
2224
2225fn extra_accepts(combo: &Row, row: &Row, axis_names: &HashSet<&str>) -> bool {
2228 for (key, value) in row {
2229 if axis_names.contains(key.as_str()) {
2230 match combo.iter().find(|(name, _)| name == key) {
2231 Some((_, existing)) if existing == value => {}
2232 _ => return false,
2233 }
2234 } else if let Some((_, existing)) = combo.iter().find(|(name, _)| name == key) {
2235 if existing != value {
2236 return false;
2237 }
2238 }
2239 }
2240 true
2241}
2242
2243fn overlay(combo: &mut Row, row: &Row) {
2244 for (key, value) in row {
2245 if let Some((_, existing)) = combo.iter_mut().find(|(name, _)| name == key) {
2246 *existing = value.clone();
2247 } else {
2248 combo.push((key.clone(), value.clone()));
2249 }
2250 }
2251}
2252
2253#[cfg(test)]
2254mod tests {
2255 use super::*;
2256
2257 fn sample_dir() -> PathBuf {
2258 Path::new(env!("CARGO_MANIFEST_DIR")).join("samples/.github/workflows")
2259 }
2260
2261 fn by_name<'a>(workflows: &'a [Workflow], name: &str) -> &'a Workflow {
2262 workflows
2263 .iter()
2264 .find(|workflow| workflow.path.file_name().and_then(|item| item.to_str()) == Some(name))
2265 .unwrap_or_else(|| panic!("missing {name}"))
2266 }
2267
2268 fn scratch(name: &str) -> PathBuf {
2269 let path = std::env::temp_dir()
2270 .join("ci-bill-workflow-tests")
2271 .join(name);
2272 let _ = fs::remove_dir_all(&path);
2273 fs::create_dir_all(&path).unwrap();
2274 path
2275 }
2276
2277 fn must_scan(name: &str, text: &str) -> Workflow {
2278 scan_yaml(Path::new(name), text).unwrap_or_else(|err| panic!("{name}: {err}"))
2279 }
2280
2281 #[test]
2282 fn sample_lint_and_ci_keep_file_line_numbers() {
2283 let workflows = scan_dir(&sample_dir()).unwrap();
2284 assert_eq!(workflows.len(), 4);
2285
2286 let lint = by_name(&workflows, "lint.yml");
2287 assert_eq!(lint.on_line, Some(2));
2288 assert!(lint.has_pull_request);
2289 assert!(!lint.cancel_in_progress);
2290 assert_eq!(lint.jobs.len(), 1);
2291 assert_eq!(lint.jobs[0].name, "lint");
2292 assert_eq!(lint.jobs[0].line, 7);
2293 assert_eq!(lint.jobs[0].display_name, None);
2294 let runs_on = lint.jobs[0].runs_on.as_ref().unwrap();
2295 assert_eq!(runs_on.line, 8);
2296 assert_eq!(runs_on.value, "macos-latest");
2297 assert_eq!(runs_on.labels, vec!["macos-latest".to_string()]);
2298 assert!(!lint.jobs[0].has_timeout_minutes);
2299 assert_eq!(
2300 lint.jobs[0].steps_text,
2301 "cargo fmt --check\ncargo clippy -- -D warnings"
2302 );
2303
2304 let ci = by_name(&workflows, "ci.yml");
2305 assert_eq!(ci.on_line, Some(2));
2306 assert!(ci.has_pull_request);
2307 assert!(ci.cancel_in_progress);
2308 assert_eq!(ci.jobs.len(), 1);
2309 assert_eq!(ci.jobs[0].name, "test");
2310 assert_eq!(ci.jobs[0].line, 8);
2311 let runs_on = ci.jobs[0].runs_on.as_ref().unwrap();
2312 assert_eq!(runs_on.line, 9);
2313 assert_eq!(runs_on.value, "ubuntu-latest");
2314 assert!(ci.jobs[0].has_timeout_minutes);
2315 assert_eq!(ci.jobs[0].steps_text, "cargo test");
2316
2317 let docs = by_name(&workflows, "docs.yml");
2318 assert_eq!(docs.on_line, Some(2));
2319 assert!(!docs.has_pull_request);
2320 assert!(!docs.cancel_in_progress);
2321 assert_eq!(docs.jobs[0].name, "docs");
2322 assert_eq!(docs.jobs[0].line, 4);
2323 assert_eq!(
2324 docs.jobs[0].runs_on.as_ref().unwrap().value,
2325 "ubuntu-latest"
2326 );
2327 assert!(!docs.jobs[0].has_timeout_minutes);
2328
2329 let release = by_name(&workflows, "release.yml");
2330 assert!(!release.has_pull_request);
2331 assert!(!release.cancel_in_progress);
2332 assert_eq!(release.jobs[0].name, "build-windows");
2333 assert_eq!(release.jobs[0].line, 6);
2334 assert_eq!(
2335 release.jobs[0].runs_on.as_ref().unwrap().value,
2336 "windows-latest"
2337 );
2338 assert!(release.jobs[0].has_timeout_minutes);
2339 assert_eq!(release.jobs[0].steps_text, "cargo build --release");
2340 }
2341
2342 #[test]
2343 fn comments_blank_lines_and_runs_on_list() {
2344 let fixture = r#"# leading comment
2345
2346name: Lint # name comment
2347
2348on: # triggers
2349 push:
2350
2351 # blank and comment before pull_request
2352 pull_request:
2353
2354concurrency:
2355 group: lint-${{ github.ref }}
2356 cancel-in-progress: false
2357
2358jobs:
2359
2360 # job comment
2361 lint:
2362 runs-on: [self-hosted, macos] # runners
2363
2364 steps:
2365 - uses: actions/checkout@v4
2366
2367 - run: cargo fmt --check
2368 - run: |
2369 cargo clippy -- -D warnings
2370 test:
2371 runs-on: ubuntu-latest
2372 timeout-minutes: 15
2373 steps:
2374 - run: cargo test
2375"#;
2376 let workflow = must_scan("lint-like.yml", fixture);
2377 assert_eq!(workflow.on_line, Some(5));
2378 assert!(workflow.has_pull_request);
2379 assert!(!workflow.cancel_in_progress);
2380 assert_eq!(workflow.jobs.len(), 2);
2381
2382 let lint = &workflow.jobs[0];
2383 assert_eq!(lint.name, "lint");
2384 assert_eq!(lint.line, 18);
2385 let runs_on = lint.runs_on.as_ref().unwrap();
2386 assert_eq!(runs_on.line, 19);
2387 assert_eq!(runs_on.value, "[self-hosted, macos]");
2388 assert_eq!(
2389 runs_on.labels,
2390 vec!["self-hosted".to_string(), "macos".to_string()]
2391 );
2392 assert!(!lint.has_timeout_minutes);
2393 assert_eq!(
2394 lint.steps_text,
2395 "cargo fmt --check\ncargo clippy -- -D warnings"
2396 );
2397
2398 let test = &workflow.jobs[1];
2399 assert_eq!(test.name, "test");
2400 assert_eq!(test.line, 27);
2401 assert_eq!(test.runs_on.as_ref().unwrap().line, 28);
2402 assert_eq!(test.runs_on.as_ref().unwrap().value, "ubuntu-latest");
2403 assert!(test.has_timeout_minutes);
2404 assert_eq!(test.steps_text, "cargo test");
2405 }
2406
2407 #[test]
2408 fn quoted_and_block_runs_on_lists() {
2409 let quoted = must_scan(
2410 "quoted.yml",
2411 "jobs:\n lint:\n runs-on: [\"self-hosted\", \"macos\"]\n",
2412 );
2413 let runs_on = quoted.jobs[0].runs_on.as_ref().unwrap();
2414 assert_eq!(runs_on.line, 3);
2415 assert_eq!(
2416 runs_on.labels,
2417 vec!["self-hosted".to_string(), "macos".to_string()]
2418 );
2419
2420 let blocked = must_scan(
2421 "block.yml",
2422 r#"jobs:
2423 lint:
2424 runs-on:
2425 - self-hosted
2426 - macos
2427 steps:
2428 - run: |
2429 echo one
2430 echo two
2431"#,
2432 );
2433 let job = &blocked.jobs[0];
2434 assert_eq!(job.line, 2);
2435 let runs_on = job.runs_on.as_ref().unwrap();
2436 assert_eq!(runs_on.line, 3);
2437 assert_eq!(
2438 runs_on.labels,
2439 vec!["self-hosted".to_string(), "macos".to_string()]
2440 );
2441 assert_eq!(runs_on.value, "[self-hosted, macos]");
2442 assert_eq!(job.steps_text, "echo one\necho two");
2443 }
2444
2445 #[test]
2446 fn inline_triggers_and_concurrency() {
2447 let pull_request = must_scan("on.yml", "on: [push, pull_request]\n");
2448 assert_eq!(pull_request.on_line, Some(1));
2449 assert!(pull_request.has_pull_request);
2450 assert!(!pull_request.cancel_in_progress);
2451
2452 let other = must_scan("on.yml", "on: [push, pull_request_target]\n");
2453 assert!(!other.has_pull_request);
2454 assert!(other.has_pull_request_target);
2455
2456 let scalar = must_scan("on.yml", "on: pull_request\n");
2457 assert_eq!(scalar.on_line, Some(1));
2458 assert!(scalar.has_pull_request);
2459
2460 let mapping = must_scan(
2461 "on.yml",
2462 "on: {push: null, pull_request: {types: [opened]}}\nconcurrency: {group: ci, cancel-in-progress: true}\n",
2463 );
2464 assert_eq!(mapping.on_line, Some(1));
2465 assert!(mapping.has_pull_request);
2466 assert!(mapping.cancel_in_progress);
2467 }
2468
2469 #[test]
2470 fn flow_jobs_keep_key_lines() {
2471 let inline = must_scan("flow.yml", "jobs: {lint: {runs-on: macos-latest}}\n");
2472 assert_eq!(inline.jobs.len(), 1);
2473 assert_eq!(inline.jobs[0].name, "lint");
2474 assert_eq!(inline.jobs[0].line, 1);
2475 let runs_on = inline.jobs[0].runs_on.as_ref().unwrap();
2476 assert_eq!(runs_on.line, 1);
2477 assert_eq!(runs_on.value, "macos-latest");
2478 assert_eq!(runs_on.labels, vec!["macos-latest".to_string()]);
2479
2480 let split = must_scan(
2481 "flow.yml",
2482 "jobs: {\n lint: {\n runs-on: macos-latest\n }\n}\n",
2483 );
2484 assert_eq!(split.jobs[0].line, 2);
2485 assert_eq!(split.jobs[0].runs_on.as_ref().unwrap().line, 3);
2486 assert_eq!(
2487 split.jobs[0].runs_on.as_ref().unwrap().value,
2488 "macos-latest"
2489 );
2490 }
2491
2492 #[test]
2493 fn trigger_forms_keep_key_lines() {
2494 let sequence = must_scan("on.yml", "on: [push, pull_request]\n");
2495 assert_eq!(sequence.on_line, Some(1));
2496 assert!(sequence.has_pull_request);
2497
2498 let quoted = must_scan("on.yml", "on: [\"push\", \"pull_request\"]\n");
2499 assert_eq!(quoted.on_line, Some(1));
2500 assert!(quoted.has_pull_request);
2501
2502 let scalar = must_scan("on.yml", "\u{feff}on: pull_request\n");
2503 assert_eq!(scalar.on_line, Some(1));
2504 assert!(scalar.has_pull_request);
2505
2506 let mapping = must_scan("on.yml", "on: {pull_request: {}}\n");
2507 assert_eq!(mapping.on_line, Some(1));
2508 assert!(mapping.has_pull_request);
2509 assert!(!mapping.cancel_in_progress);
2510
2511 let disabled = must_scan(
2512 "on.yml",
2513 "on: {pull_request: {}}\nconcurrency: {cancel-in-progress: false}\n",
2514 );
2515 assert!(disabled.has_pull_request);
2516 assert!(!disabled.cancel_in_progress);
2517
2518 let quoted_true = must_scan(
2519 "on.yml",
2520 "on: pull_request\nconcurrency:\n cancel-in-progress: \"true\"\n",
2521 );
2522 assert_eq!(quoted_true.on_line, Some(1));
2523 assert!(quoted_true.cancel_in_progress);
2524 }
2525
2526 #[test]
2527 fn quoted_keys_and_values_keep_lines() {
2528 let workflow = must_scan(
2529 "quoted.yml",
2530 "on: \"pull_request\"\njobs:\n \"lint\":\n \"runs-on\": \"macos-latest\"\n steps:\n - run: \"cargo fmt --check\"\n",
2531 );
2532 assert_eq!(workflow.on_line, Some(1));
2533 assert!(workflow.has_pull_request);
2534 assert_eq!(workflow.jobs[0].name, "lint");
2535 assert_eq!(workflow.jobs[0].line, 3);
2536 let runs_on = workflow.jobs[0].runs_on.as_ref().unwrap();
2537 assert_eq!(runs_on.line, 4);
2538 assert_eq!(runs_on.value, "macos-latest");
2539 assert_eq!(runs_on.labels, vec!["macos-latest".to_string()]);
2540 assert_eq!(workflow.jobs[0].steps_text, "cargo fmt --check");
2541
2542 let single = must_scan(
2543 "quoted.yml",
2544 "jobs:\n 'lint':\n runs-on: 'windows-latest'\n steps:\n - 'run': 'cargo test'\n",
2545 );
2546 assert_eq!(single.jobs[0].line, 2);
2547 assert_eq!(single.jobs[0].runs_on.as_ref().unwrap().line, 3);
2548 assert_eq!(
2549 single.jobs[0].runs_on.as_ref().unwrap().value,
2550 "windows-latest"
2551 );
2552 assert_eq!(single.jobs[0].steps_text, "cargo test");
2553 }
2554
2555 #[test]
2556 fn runs_on_scalar_list_and_map_keep_lines() {
2557 let scalar = must_scan("runs.yml", "jobs:\n test:\n runs-on: ubuntu-latest\n");
2558 let scalar_runs = scalar.jobs[0].runs_on.as_ref().unwrap();
2559 assert_eq!(scalar.jobs[0].line, 2);
2560 assert_eq!(scalar_runs.line, 3);
2561 assert_eq!(scalar_runs.value, "ubuntu-latest");
2562 assert_eq!(scalar_runs.labels, vec!["ubuntu-latest".to_string()]);
2563
2564 let flow_list = must_scan(
2565 "runs.yml",
2566 "jobs:\n lint:\n runs-on: [self-hosted, macos]\n",
2567 );
2568 let flow_runs = flow_list.jobs[0].runs_on.as_ref().unwrap();
2569 assert_eq!(flow_runs.line, 3);
2570 assert_eq!(flow_runs.value, "[self-hosted, macos]");
2571 assert_eq!(
2572 flow_runs.labels,
2573 vec!["self-hosted".to_string(), "macos".to_string()]
2574 );
2575
2576 let flow_map = must_scan(
2577 "runs.yml",
2578 "jobs:\n lint:\n runs-on: {group: large, labels: [self-hosted, macos]}\n",
2579 );
2580 let map_runs = flow_map.jobs[0].runs_on.as_ref().unwrap();
2581 assert_eq!(flow_map.jobs[0].line, 2);
2582 assert_eq!(map_runs.line, 3);
2583 assert_eq!(
2584 map_runs.value,
2585 "{group: large, labels: [self-hosted, macos]}"
2586 );
2587 assert_eq!(
2588 map_runs.labels,
2589 vec!["self-hosted".to_string(), "macos".to_string()]
2590 );
2591
2592 let block_map = must_scan(
2593 "runs.yml",
2594 "jobs:\n lint:\n runs-on:\n group: large\n labels:\n - self-hosted\n - windows\n",
2595 );
2596 let block_runs = block_map.jobs[0].runs_on.as_ref().unwrap();
2597 assert_eq!(block_runs.line, 3);
2598 assert_eq!(
2599 block_runs.value,
2600 "{group: large, labels: [self-hosted, windows]}"
2601 );
2602 assert_eq!(
2603 block_runs.labels,
2604 vec!["self-hosted".to_string(), "windows".to_string()]
2605 );
2606
2607 let label_scalar = must_scan(
2608 "runs.yml",
2609 "jobs:\n lint:\n runs-on: {group: large, labels: macos-latest}\n",
2610 );
2611 let label_runs = label_scalar.jobs[0].runs_on.as_ref().unwrap();
2612 assert_eq!(label_runs.line, 3);
2613 assert_eq!(label_runs.value, "{group: large, labels: macos-latest}");
2614 assert_eq!(label_runs.labels, vec!["macos-latest".to_string()]);
2615
2616 let group_only = must_scan("runs.yml", "jobs:\n test:\n runs-on: {group: large}\n");
2617 let group_runs = group_only.jobs[0].runs_on.as_ref().unwrap();
2618 assert_eq!(group_runs.line, 3);
2619 assert_eq!(group_runs.value, "{group: large}");
2620 assert!(group_runs.labels.is_empty());
2621 }
2622
2623 #[test]
2624 fn multiline_run_keeps_script_lines() {
2625 let workflow = must_scan(
2626 "run.yml",
2627 "jobs:\n lint:\n steps:\n - run: |\n echo one\n echo two\n - run: cargo fmt --check\n",
2628 );
2629 assert_eq!(workflow.jobs[0].line, 2);
2630 assert_eq!(
2631 workflow.jobs[0].steps_text,
2632 "echo one\necho two\ncargo fmt --check"
2633 );
2634 }
2635
2636 #[test]
2637 fn anchors_and_merge_keys_keep_source_lines() {
2638 let overridden = must_scan(
2639 "anchor.yml",
2640 "defaults: &defaults\n runs-on: ubuntu-latest\n timeout-minutes: 10\n steps:\n - run: cargo test\non: pull_request\njobs:\n lint:\n <<: *defaults\n runs-on: macos-latest\n steps:\n - run: cargo clippy -- -D warnings\n test:\n <<: *defaults\n",
2641 );
2642 assert_eq!(overridden.on_line, Some(6));
2643 assert!(overridden.has_pull_request);
2644 assert!(!overridden.cancel_in_progress);
2645 assert_eq!(overridden.jobs.len(), 2);
2646
2647 let lint = &overridden.jobs[0];
2648 assert_eq!(lint.name, "lint");
2649 assert_eq!(lint.line, 8);
2650 assert!(lint.has_timeout_minutes);
2651 let lint_runs = lint.runs_on.as_ref().unwrap();
2652 assert_eq!(lint_runs.line, 10);
2653 assert_eq!(lint_runs.value, "macos-latest");
2654 assert_eq!(lint_runs.labels, vec!["macos-latest".to_string()]);
2655 assert_eq!(lint.steps_text, "cargo clippy -- -D warnings");
2656
2657 let test = &overridden.jobs[1];
2658 assert_eq!(test.name, "test");
2659 assert_eq!(test.line, 13);
2660 assert!(test.has_timeout_minutes);
2661 let test_runs = test.runs_on.as_ref().unwrap();
2662 assert_eq!(test_runs.line, 2);
2663 assert_eq!(test_runs.value, "ubuntu-latest");
2664 assert_eq!(test.steps_text, "cargo test");
2665
2666 let inherited = must_scan(
2667 "anchor.yml",
2668 "defaults: &defaults\n runs-on: macos-latest\n steps:\n - run: |\n cargo fmt --check\njobs:\n lint:\n <<: *defaults\n",
2669 );
2670 assert_eq!(inherited.jobs[0].line, 7);
2671 let runs_on = inherited.jobs[0].runs_on.as_ref().unwrap();
2672 assert_eq!(runs_on.line, 2);
2673 assert_eq!(runs_on.value, "macos-latest");
2674 assert_eq!(inherited.jobs[0].steps_text, "cargo fmt --check");
2675 assert!(!inherited.jobs[0].has_timeout_minutes);
2676
2677 let explicit_first = must_scan(
2678 "anchor.yml",
2679 "defaults: &defaults\n runs-on: ubuntu-latest\njobs:\n lint:\n runs-on: windows-latest\n <<: *defaults\n",
2680 );
2681 let runs_on = explicit_first.jobs[0].runs_on.as_ref().unwrap();
2682 assert_eq!(explicit_first.jobs[0].line, 4);
2683 assert_eq!(runs_on.line, 5);
2684 assert_eq!(runs_on.value, "windows-latest");
2685
2686 let sequence = must_scan(
2687 "anchor.yml",
2688 "linux: &linux\n runs-on: ubuntu-latest\n timeout-minutes: 5\nextra: &extra\n steps:\n - run: cargo test\njobs:\n test:\n <<: [*linux, *extra]\n",
2689 );
2690 assert_eq!(sequence.jobs[0].line, 8);
2691 assert!(sequence.jobs[0].has_timeout_minutes);
2692 assert_eq!(sequence.jobs[0].runs_on.as_ref().unwrap().line, 2);
2693 assert_eq!(
2694 sequence.jobs[0].runs_on.as_ref().unwrap().value,
2695 "ubuntu-latest"
2696 );
2697 assert_eq!(sequence.jobs[0].steps_text, "cargo test");
2698
2699 let earlier_wins = must_scan(
2700 "anchor.yml",
2701 "first: &first\n runs-on: macos-latest\nsecond: &second\n runs-on: ubuntu-latest\njobs:\n lint:\n <<: [*first, *second]\n",
2702 );
2703 let runs_on = earlier_wins.jobs[0].runs_on.as_ref().unwrap();
2704 assert_eq!(earlier_wins.jobs[0].line, 6);
2705 assert_eq!(runs_on.line, 2);
2706 assert_eq!(runs_on.value, "macos-latest");
2707
2708 let named = must_scan(
2709 "anchor.yml",
2710 "defaults: &defaults\n name: clippy\n runs-on: macos-latest\n timeout-minutes: 5\njobs:\n build:\n <<: *defaults\n",
2711 );
2712 assert_eq!(named.jobs[0].name, "build");
2713 assert_eq!(named.jobs[0].line, 6);
2714 assert_eq!(named.jobs[0].display_name.as_deref(), Some("clippy"));
2715 assert_eq!(named.jobs[0].runs_on.as_ref().unwrap().line, 3);
2716 assert!(named.jobs[0].has_timeout_minutes);
2717 }
2718
2719 #[test]
2720 fn billion_laughs_sequence_aliases_are_a_parse_error() {
2721 let text = "a0: &a0 [\"x\",\"x\",\"x\",\"x\",\"x\",\"x\",\"x\",\"x\",\"x\",\"x\"]\n\
2722a1: &a1 [*a0,*a0,*a0,*a0,*a0,*a0,*a0,*a0,*a0,*a0]\n\
2723a2: &a2 [*a1,*a1,*a1,*a1,*a1,*a1,*a1,*a1,*a1,*a1]\n\
2724a3: &a3 [*a2,*a2,*a2,*a2,*a2,*a2,*a2,*a2,*a2,*a2]\n\
2725a4: &a4 [*a3,*a3,*a3,*a3,*a3,*a3,*a3,*a3,*a3,*a3]\n\
2726a5: &a5 [*a4,*a4,*a4,*a4,*a4,*a4,*a4,*a4,*a4,*a4]\n\
2727a6: &a6 [*a5,*a5,*a5,*a5,*a5,*a5,*a5,*a5,*a5,*a5]\n\
2728a7: &a7 [*a6,*a6,*a6,*a6,*a6,*a6,*a6,*a6,*a6,*a6]\n\
2729a8: &a8 [*a7,*a7,*a7,*a7,*a7,*a7,*a7,*a7,*a7,*a7]\n\
2730jobs:\n job1:\n runs-on: ubuntu-latest\n env: *a8\n";
2731 let err = scan_yaml(Path::new("laughs.yml"), text).expect_err("laughs.yml");
2732 assert_eq!(err.path, Path::new("laughs.yml"));
2733 assert!(matches!(err.kind, ScanErrorKind::Yaml { .. }), "{err}");
2734 }
2735
2736 #[test]
2737 fn billion_laughs_merge_key_aliases_are_a_parse_error() {
2738 let mut text =
2739 String::from("a0: &a0 [\"x\",\"x\",\"x\",\"x\",\"x\",\"x\",\"x\",\"x\",\"x\",\"x\"]\n");
2740 for level in 1..=8 {
2741 let previous = level - 1;
2742 text.push_str(&format!("a{level}: &a{level} ["));
2743 for i in 0..10 {
2744 if i > 0 {
2745 text.push(',');
2746 }
2747 text.push_str(&format!("*a{previous}"));
2748 }
2749 text.push_str("]\n");
2750 }
2751 text.push_str("jobs:\n job1:\n runs-on: ubuntu-latest\n <<: *a8\n");
2752 let err = scan_yaml(Path::new("laughs-merge.yml"), &text).expect_err("laughs-merge.yml");
2753 assert_eq!(err.path, Path::new("laughs-merge.yml"));
2754 assert!(matches!(err.kind, ScanErrorKind::Yaml { .. }), "{err}");
2755 }
2756
2757 #[test]
2758 fn repeated_non_multiplying_alias_reuse_stays_within_budget() {
2759 let mut text =
2760 String::from("defaults: &defaults\n runs-on: ubuntu-latest\n timeout-minutes: 5\n");
2761 text.push_str("jobs:\n");
2762 for i in 0..500 {
2763 text.push_str(&format!(" job{i}:\n <<: *defaults\n"));
2764 }
2765 let workflow = must_scan("many-jobs.yml", &text);
2766 assert_eq!(workflow.jobs.len(), 500);
2767 for job in &workflow.jobs {
2768 assert_eq!(job.runs_on.as_ref().unwrap().value, "ubuntu-latest");
2769 assert!(job.has_timeout_minutes);
2770 }
2771 }
2772
2773 #[test]
2774 fn odd_but_valid_yaml_returns_a_workflow() {
2775 let empty = must_scan("odd.yml", "");
2776 assert!(empty.jobs.is_empty());
2777 assert_eq!(empty.on_line, None);
2778
2779 let listed = must_scan("odd.yml", "jobs:\n - just-a-list\n");
2780 assert!(listed.jobs.is_empty());
2781
2782 let workflow = must_scan(
2783 "odd.yml",
2784 "jobs:\n good:\n runs-on: ubuntu-latest\n steps:\n - run: echo ok\n :::\n",
2785 );
2786 assert_eq!(workflow.jobs.len(), 1);
2787 assert_eq!(workflow.jobs[0].name, "good");
2788 assert_eq!(
2789 workflow.jobs[0].runs_on.as_ref().unwrap().value,
2790 "ubuntu-latest"
2791 );
2792 assert_eq!(workflow.jobs[0].steps_text, "echo ok");
2793 }
2794
2795 #[test]
2796 fn invalid_yaml_reports_line_without_content() {
2797 let cases = [
2798 ("broken-colons.yml", "::::\n{{\n", 3),
2799 ("broken-flow.yml", "jobs: [\n", 2),
2800 ("broken-tabs.yml", "\tjobs:\n\t\tlint:\n", 2),
2801 ("broken-braces.yml", "on: {{{{pull_request:\n", 2),
2802 ("broken-alias.yml", "a: &a\n b: *a\n", 2),
2803 (
2804 "broken-missing.yml",
2805 "jobs:\n lint:\n <<: *missing\n",
2806 3,
2807 ),
2808 ];
2809 for (name, text, line) in cases {
2810 let err = scan_yaml(Path::new(name), text).expect_err(name);
2811 assert_eq!(err.path, Path::new(name), "{name}");
2812 assert_eq!(
2813 err.to_string(),
2814 format!("line {line}: invalid YAML"),
2815 "{name}"
2816 );
2817 assert!(!err.to_string().contains("pull_request"), "{name}: {err}");
2818 assert!(!err.to_string().contains("jobs"), "{name}: {err}");
2819 assert!(!err.to_string().contains("lint"), "{name}: {err}");
2820 assert!(!err.to_string().contains("missing"), "{name}: {err}");
2821 assert!(!err.to_string().contains('*'), "{name}: {err}");
2822 }
2823
2824 let dir = scratch("invalid-yaml");
2825 let secret = "SECRET_TOKEN_XYZ";
2826 fs::write(dir.join("ok.yml"), "name: Ok\n").unwrap();
2827 fs::write(
2828 dir.join("broken.yml"),
2829 format!("name: Demo\njobs: [\n {secret}\n"),
2830 )
2831 .unwrap();
2832 let err = scan_dir(&dir).unwrap_err();
2833 assert_eq!(err.path, dir.join("broken.yml"));
2834 assert_eq!(err.to_string(), "line 4: invalid YAML");
2835 assert!(!err.to_string().contains(secret), "{err}");
2836 }
2837
2838 #[test]
2839 fn a_single_leading_document_marker_or_trailing_end_marker_still_works() {
2840 let leading = must_scan(
2841 "leading.yml",
2842 "---\njobs:\n a:\n runs-on: ubuntu-latest\n",
2843 );
2844 assert_eq!(leading.jobs.len(), 1);
2845 assert_eq!(leading.jobs[0].name, "a");
2846
2847 let trailing_end = must_scan(
2848 "trailing-end.yml",
2849 "jobs:\n a:\n runs-on: ubuntu-latest\n...\n",
2850 );
2851 assert_eq!(trailing_end.jobs.len(), 1);
2852 assert_eq!(trailing_end.jobs[0].name, "a");
2853
2854 let both = must_scan(
2855 "both.yml",
2856 "---\njobs:\n a:\n runs-on: ubuntu-latest\n...\n",
2857 );
2858 assert_eq!(both.jobs.len(), 1);
2859 assert_eq!(both.jobs[0].name, "a");
2860 }
2861
2862 #[test]
2863 fn a_second_yaml_document_is_a_parse_error_instead_of_a_silent_drop() {
2864 let cases = [
2865 (
2866 "two-docs.yml",
2867 "jobs:\n a:\n runs-on: ubuntu-latest\n---\njobs:\n b:\n runs-on: macos-latest\n",
2868 4,
2869 ),
2870 (
2871 "second-empty.yml",
2872 "jobs:\n a:\n runs-on: ubuntu-latest\n---\n",
2873 4,
2874 ),
2875 (
2876 "crlf-two-docs.yml",
2877 "jobs:\r\n a:\r\n runs-on: ubuntu-latest\r\n---\r\njobs:\r\n b:\r\n runs-on: macos-latest\r\n",
2878 4,
2879 ),
2880 ];
2881 for (name, text, line) in cases {
2882 let err = scan_yaml(Path::new(name), text).expect_err(name);
2883 assert_eq!(err.path, Path::new(name), "{name}");
2884 assert_eq!(
2885 err.to_string(),
2886 format!("line {line}: invalid YAML"),
2887 "{name}"
2888 );
2889 assert!(!err.to_string().contains("macos"), "{name}: {err}");
2890 }
2891 }
2892
2893 #[test]
2894 fn arbitrary_text_does_not_panic() {
2895 let samples = [
2896 "",
2897 "jobs:\n - just-a-list\n",
2898 "jobs:\n good:\n runs-on: ubuntu-latest\n steps:\n - run: echo ok\n :::\n",
2899 "::::\n{{\n",
2900 "jobs: [\n",
2901 "\tjobs:\n\t\tlint:\n",
2902 "on: {{{{pull_request:\n",
2903 "\0\0\0",
2904 "\"unterminated",
2905 "a: &a\n b: *a\n",
2906 "jobs:\n lint:\n <<: *missing\n",
2907 &"[".repeat(64),
2908 ];
2909 for text in samples {
2910 let _ = scan_yaml(Path::new("odd.yml"), text);
2911 }
2912 }
2913
2914 #[test]
2915 fn reads_yml_and_yaml_but_not_nested_files() {
2916 let dir = scratch("workflow-ext");
2917 fs::write(
2918 dir.join("a.yml"),
2919 "jobs:\n build:\n runs-on: ubuntu-latest\n",
2920 )
2921 .unwrap();
2922 fs::write(dir.join("b.yaml"), "name: B\n").unwrap();
2923 fs::write(dir.join("notes.txt"), "jobs:\n hidden:\n").unwrap();
2924 let nested = dir.join("nested");
2925 fs::create_dir(&nested).unwrap();
2926 fs::write(
2927 nested.join("c.yml"),
2928 "jobs:\n hidden:\n runs-on: macos-latest\n",
2929 )
2930 .unwrap();
2931
2932 let found = scan_dir(&dir).unwrap();
2933 let names: Vec<_> = found
2934 .iter()
2935 .filter_map(|workflow| {
2936 workflow
2937 .path
2938 .file_name()
2939 .and_then(|name| name.to_str())
2940 .map(str::to_string)
2941 })
2942 .collect();
2943 assert_eq!(names, vec!["a.yml".to_string(), "b.yaml".to_string()]);
2944 assert_eq!(found[0].jobs[0].name, "build");
2945 assert!(found[1].jobs.is_empty());
2946 }
2947
2948 #[test]
2949 fn missing_directory_is_an_error() {
2950 let err = scan_dir(Path::new("/no/such/ci-bill-workflows")).unwrap_err();
2951 assert_eq!(err.to_string(), "cannot read directory");
2952 assert_eq!(err.path, Path::new("/no/such/ci-bill-workflows"));
2953 }
2954
2955 fn job_matrix(text: &str) -> Matrix {
2956 let workflow = must_scan("matrix.yml", text);
2957 assert_eq!(workflow.jobs.len(), 1, "{text}");
2958 workflow.jobs[0]
2959 .matrix
2960 .clone()
2961 .unwrap_or_else(|| panic!("missing matrix in {text}"))
2962 }
2963
2964 #[test]
2965 fn matrix_two_by_three_flow_and_block_lists_are_six() {
2966 let flow = job_matrix(
2967 "jobs:\n build:\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16, 18]\n",
2968 );
2969 assert_eq!(flow.line, 4);
2970 assert_eq!(flow.jobs, 6);
2971 assert!(!flow.at_least);
2972
2973 let block = job_matrix(
2974 "jobs:\n build:\n strategy:\n matrix:\n os:\n - ubuntu-latest\n - windows-latest\n node:\n - 14\n - 16\n - 18\n",
2975 );
2976 assert_eq!(block.line, 4);
2977 assert_eq!(block.jobs, 6);
2978 assert!(!block.at_least);
2979
2980 let mixed = job_matrix(
2981 "jobs:\n build:\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node:\n - 14\n - 16\n - 18\n",
2982 );
2983 assert_eq!(mixed.jobs, 6);
2984 assert!(!mixed.at_least);
2985 }
2986
2987 #[test]
2988 fn matrix_two_by_two_is_four() {
2989 let matrix = job_matrix(
2990 "jobs:\n build:\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16]\n",
2991 );
2992 assert_eq!(matrix.line, 4);
2993 assert_eq!(matrix.jobs, 4);
2994 assert!(!matrix.at_least);
2995 }
2996
2997 #[test]
2998 fn matrix_exclude_removes_matching_combinations_only() {
2999 let one = job_matrix(
3000 "jobs:\n build:\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16, 18]\n exclude:\n - os: ubuntu-latest\n node: 14\n",
3001 );
3002 assert_eq!(one.line, 4);
3003 assert_eq!(one.jobs, 5);
3004 assert!(!one.at_least);
3005
3006 let flow = job_matrix(
3007 "jobs:\n build:\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16, 18]\n exclude: [{os: ubuntu-latest, node: 14}]\n",
3008 );
3009 assert_eq!(flow.jobs, 5);
3010
3011 let duplicate = job_matrix(
3012 "jobs:\n build:\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16, 18]\n exclude:\n - os: ubuntu-latest\n node: 14\n - os: ubuntu-latest\n node: 14\n",
3013 );
3014 assert_eq!(duplicate.jobs, 5);
3015
3016 let absent = job_matrix(
3017 "jobs:\n build:\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16]\n exclude:\n - os: macos-latest\n node: 14\n",
3018 );
3019 assert_eq!(absent.jobs, 4);
3020
3021 let partial = job_matrix(
3022 "jobs:\n build:\n strategy:\n matrix:\n os: [macos-latest, windows-latest]\n version: [12, 14, 16]\n environment: [staging, production]\n exclude:\n - os: macos-latest\n version: 12\n environment: production\n - os: windows-latest\n version: 16\n",
3023 );
3024 assert_eq!(partial.jobs, 9);
3025 assert!(!partial.at_least);
3026 }
3027
3028 #[test]
3029 fn matrix_include_adds_only_new_combinations() {
3030 let added = job_matrix(
3031 "jobs:\n build:\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16]\n include:\n - os: macos-latest\n node: 18\n - os: macos-latest\n node: 20\n",
3032 );
3033 assert_eq!(added.line, 4);
3034 assert_eq!(added.jobs, 6);
3035 assert!(!added.at_least);
3036
3037 let flow = job_matrix(
3038 "jobs:\n build:\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16]\n include: [{os: macos-latest, node: 18}, {os: macos-latest, node: 20}]\n",
3039 );
3040 assert_eq!(flow.jobs, 6);
3041
3042 let expanded = job_matrix(
3043 "jobs:\n build:\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16, 18]\n include:\n - os: ubuntu-latest\n node: 14\n npm: 6\n - color: green\n",
3044 );
3045 assert_eq!(expanded.jobs, 6);
3046
3047 let small_expanded = job_matrix(
3048 "jobs:\n build:\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16]\n include:\n - os: ubuntu-latest\n node: 14\n npm: 6\n",
3049 );
3050 assert_eq!(small_expanded.jobs, 4);
3051
3052 let fruit = job_matrix(
3053 "jobs:\n test:\n strategy:\n matrix:\n fruit: [apple, pear]\n animal: [cat, dog]\n include:\n - color: green\n - color: pink\n animal: cat\n - fruit: apple\n shape: circle\n - fruit: banana\n - fruit: banana\n animal: cat\n",
3054 );
3055 assert_eq!(fruit.jobs, 6);
3056
3057 let include_only = job_matrix(
3058 "jobs:\n build:\n strategy:\n matrix:\n include:\n - site: production\n - site: staging\n - site: dev\n - site: qa\n - site: eu\n - site: us\n",
3059 );
3060 assert_eq!(include_only.jobs, 6);
3061 assert!(!include_only.at_least);
3062
3063 let five = job_matrix(
3064 "jobs:\n build:\n strategy:\n matrix:\n include:\n - site: production\n - site: staging\n - site: dev\n - site: qa\n - site: eu\n",
3065 );
3066 assert_eq!(five.jobs, 5);
3067
3068 let added_back = job_matrix(
3069 "jobs:\n build:\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16]\n exclude:\n - os: ubuntu-latest\n node: 14\n include:\n - os: ubuntu-latest\n node: 14\n npm: 6\n",
3070 );
3071 assert_eq!(added_back.jobs, 4);
3072 }
3073
3074 #[test]
3075 fn matrix_expression_axis_is_at_least_the_rest() {
3076 let wide = job_matrix(
3077 "jobs:\n build:\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16, 18]\n extra: ${{ fromJSON(needs.setup.outputs.extra) }}\n",
3078 );
3079 assert_eq!(wide.line, 4);
3080 assert_eq!(wide.jobs, 6);
3081 assert!(wide.at_least);
3082
3083 let rest = job_matrix(
3084 "jobs:\n build:\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: ${{ fromJSON(needs.setup.outputs.node) }}\n",
3085 );
3086 assert_eq!(rest.line, 4);
3087 assert_eq!(rest.jobs, 2);
3088 assert!(rest.at_least);
3089
3090 let quoted = job_matrix(
3091 "jobs:\n build:\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: \"${{ fromJSON(needs.setup.outputs.node) }}\"\n",
3092 );
3093 assert_eq!(quoted.jobs, 2);
3094 assert!(quoted.at_least);
3095
3096 let only = job_matrix(
3097 "jobs:\n build:\n strategy:\n matrix:\n node: ${{ fromJSON(needs.setup.outputs.node) }}\n",
3098 );
3099 assert_eq!(only.jobs, 1);
3100 assert!(only.at_least);
3101
3102 let embedded = job_matrix(
3103 "jobs:\n build:\n strategy:\n matrix:\n os: ubuntu-${{ matrix.extra }}\n node: [14, 16, 18, 20, 22, 24]\n",
3104 );
3105 assert_eq!(embedded.jobs, 6);
3106 assert!(!embedded.at_least);
3107
3108 let list_item = job_matrix(
3109 "jobs:\n build:\n strategy:\n matrix:\n os: [ubuntu-latest, \"${{ matrix.extra }}\"]\n node: [14, 16, 18]\n",
3110 );
3111 assert_eq!(list_item.jobs, 6);
3112 assert!(!list_item.at_least);
3113
3114 let whole = job_matrix(
3115 "jobs:\n build:\n strategy:\n matrix: ${{ fromJSON(needs.setup.outputs.matrix) }}\n",
3116 );
3117 assert_eq!(whole.line, 4);
3118 assert_eq!(whole.jobs, 1);
3119 assert!(whole.at_least);
3120
3121 let dynamic_include = job_matrix(
3122 "jobs:\n build:\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16, 18]\n include: ${{ fromJSON(needs.setup.outputs.include) }}\n",
3123 );
3124 assert_eq!(dynamic_include.jobs, 6);
3125 assert!(dynamic_include.at_least);
3126 }
3127
3128 #[test]
3129 fn matrix_flow_style_keeps_the_matrix_key_line() {
3130 let inline = job_matrix(
3131 "jobs: {build: {strategy: {matrix: {os: [ubuntu-latest, windows-latest], node: [14, 16, 18]}}}}\n",
3132 );
3133 assert_eq!(inline.line, 1);
3134 assert_eq!(inline.jobs, 6);
3135 assert!(!inline.at_least);
3136
3137 let split = job_matrix(
3138 "jobs:\n build:\n timeout-minutes: 5\n strategy:\n matrix: {os: [ubuntu-latest, windows-latest], node: [14, 16, 18]}\n",
3139 );
3140 assert_eq!(split.line, 5);
3141 assert_eq!(split.jobs, 6);
3142
3143 let commented = job_matrix(
3144 "jobs:\n build:\n timeout-minutes: 5\n strategy:\n # keep going\n fail-fast: false\n\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16, 18]\n",
3145 );
3146 assert_eq!(commented.line, 8);
3147 assert_eq!(commented.jobs, 6);
3148 }
3149
3150 #[test]
3151 fn matrix_anchor_alias_and_object_values() {
3152 let anchored = job_matrix(
3153 "defaults: &defaults\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16, 18]\njobs:\n build:\n <<: *defaults\n timeout-minutes: 5\n",
3154 );
3155 assert_eq!(anchored.line, 3);
3156 assert_eq!(anchored.jobs, 6);
3157
3158 let alias = job_matrix(
3159 "oses: &oses\n - ubuntu-latest\n - windows-latest\njobs:\n build:\n strategy:\n matrix:\n os: *oses\n node: [14, 16, 18]\n",
3160 );
3161 assert_eq!(alias.line, 7);
3162 assert_eq!(alias.jobs, 6);
3163
3164 let objects = job_matrix(
3165 "jobs:\n build:\n strategy:\n matrix:\n os:\n - ubuntu-latest\n - macos-latest\n node:\n - version: 14\n - version: 20\n env: legacy\n",
3166 );
3167 assert_eq!(objects.jobs, 4);
3168 assert!(!objects.at_least);
3169
3170 let scalar = job_matrix(
3171 "jobs:\n build:\n strategy:\n matrix:\n os: ubuntu-latest\n node: [14, 16, 18, 20, 22, 24]\n",
3172 );
3173 assert_eq!(scalar.jobs, 6);
3174
3175 let empty = job_matrix(
3176 "jobs:\n build:\n strategy:\n matrix:\n os: []\n node: [14, 16, 18, 20, 22, 24]\n",
3177 );
3178 assert_eq!(empty.jobs, 0);
3179 assert!(!empty.at_least);
3180
3181 let workflow = must_scan(
3182 "matrix.yml",
3183 "jobs:\n build:\n strategy:\n fail-fast: false\n max-parallel: 2\n",
3184 );
3185 assert!(workflow.jobs[0].matrix.is_none());
3186 }
3187
3188 fn crlf(text: &str) -> String {
3189 text.replace('\n', "\r\n")
3190 }
3191
3192 #[test]
3193 fn duplicate_runs_on_keeps_the_later_value() {
3194 let cases = [
3195 (
3196 "block",
3197 "jobs:\n lint:\n runs-on: ubuntu-latest\n runs-on: macos-latest\n",
3198 "macos-latest",
3199 3,
3200 4,
3201 ),
3202 (
3203 "crlf",
3204 "jobs:\r\n lint:\r\n runs-on: ubuntu-latest\r\n runs-on: macos-latest\r\n",
3205 "macos-latest",
3206 3,
3207 4,
3208 ),
3209 (
3210 "reversed",
3211 "jobs:\n lint:\n runs-on: macos-latest\n runs-on: ubuntu-latest\n",
3212 "ubuntu-latest",
3213 3,
3214 4,
3215 ),
3216 (
3217 "quoted",
3218 "jobs:\n lint:\n runs-on: \"ubuntu-latest\"\n runs-on: \"macos-latest\"\n",
3219 "macos-latest",
3220 3,
3221 4,
3222 ),
3223 (
3224 "capitals",
3225 "jobs:\n lint:\n runs-on: ubuntu-latest\n runs-on: MacOS-latest\n",
3226 "MacOS-latest",
3227 3,
3228 4,
3229 ),
3230 (
3231 "flow",
3232 "jobs:\n lint: {\n runs-on: ubuntu-latest,\n runs-on: macos-latest\n }\n",
3233 "macos-latest",
3234 3,
3235 4,
3236 ),
3237 (
3238 "empty-then-value",
3239 "jobs:\n lint:\n runs-on: \"\"\n runs-on: macos-latest\n",
3240 "macos-latest",
3241 3,
3242 4,
3243 ),
3244 (
3245 "value-then-empty",
3246 "jobs:\n lint:\n runs-on: ubuntu-latest\n runs-on: \"\"\n",
3247 "",
3248 3,
3249 4,
3250 ),
3251 (
3252 "list-wins",
3253 "jobs:\n lint:\n runs-on: ubuntu-latest\n runs-on: [macos-latest]\n",
3254 "[macos-latest]",
3255 3,
3256 4,
3257 ),
3258 ];
3259 for (name, text, value, first, second) in cases {
3260 let workflow = must_scan(name, text);
3261 assert_eq!(workflow.jobs.len(), 1, "{name}");
3262 let runs_on = workflow.jobs[0].runs_on.as_ref().unwrap();
3263 assert_eq!(runs_on.value, value, "{name}");
3264 assert_eq!(runs_on.line, second, "{name}");
3265 assert_eq!(workflow.duplicate_keys.len(), 1, "{name}");
3266 assert_eq!(workflow.duplicate_keys[0].key, "runs-on");
3267 assert_eq!(workflow.duplicate_keys[0].first, first, "{name}");
3268 assert_eq!(workflow.duplicate_keys[0].second, second, "{name}");
3269 }
3270
3271 let third = must_scan(
3272 "three.yml",
3273 "jobs:\n lint:\n runs-on: ubuntu-latest\n runs-on: windows-latest\n runs-on: macos-latest\n",
3274 );
3275 assert_eq!(
3276 third.jobs[0].runs_on.as_ref().unwrap().value,
3277 "macos-latest"
3278 );
3279 assert_eq!(third.duplicate_keys.len(), 2);
3280 assert_eq!(third.duplicate_keys[0].first, 3);
3281 assert_eq!(third.duplicate_keys[0].second, 4);
3282 assert_eq!(third.duplicate_keys[1].first, 4);
3283 assert_eq!(third.duplicate_keys[1].second, 5);
3284
3285 let same_line = must_scan(
3286 "flow.yml",
3287 "jobs: {lint: {runs-on: ubuntu-latest, runs-on: macos-latest}}\n",
3288 );
3289 assert_eq!(
3290 same_line.jobs[0].runs_on.as_ref().unwrap().value,
3291 "macos-latest"
3292 );
3293 assert_eq!(same_line.duplicate_keys[0].first, 1);
3294 assert_eq!(same_line.duplicate_keys[0].second, 1);
3295
3296 let distinct = must_scan(
3297 "case.yml",
3298 "jobs:\n lint:\n runs-on: ubuntu-latest\n Runs-on: macos-latest\n",
3299 );
3300 assert_eq!(
3301 distinct.jobs[0].runs_on.as_ref().unwrap().value,
3302 "ubuntu-latest"
3303 );
3304 assert!(distinct.duplicate_keys.is_empty());
3305
3306 let missing = must_scan("one.yml", "jobs:\n lint:\n runs-on: ubuntu-latest\n");
3307 assert!(missing.duplicate_keys.is_empty());
3308 }
3309
3310 #[test]
3311 fn merge_key_does_not_override_an_explicit_key() {
3312 let explicit_last = must_scan(
3313 "anchor.yml",
3314 "defaults: &defaults\n runs-on: ubuntu-latest\njobs:\n lint:\n <<: *defaults\n runs-on: macos-latest\n",
3315 );
3316 let runs_on = explicit_last.jobs[0].runs_on.as_ref().unwrap();
3317 assert_eq!(runs_on.value, "macos-latest");
3318 assert_eq!(runs_on.line, 6);
3319 assert!(explicit_last.duplicate_keys.is_empty());
3320
3321 let explicit_first = must_scan(
3322 "anchor.yml",
3323 "defaults: &defaults\n runs-on: ubuntu-latest\njobs:\n lint:\n runs-on: windows-latest\n <<: *defaults\n",
3324 );
3325 let runs_on = explicit_first.jobs[0].runs_on.as_ref().unwrap();
3326 assert_eq!(runs_on.value, "windows-latest");
3327 assert_eq!(runs_on.line, 5);
3328 assert!(explicit_first.duplicate_keys.is_empty());
3329
3330 let crlf = must_scan(
3331 "anchor.yml",
3332 &crlf("defaults: &defaults\n runs-on: ubuntu-latest\njobs:\n lint:\n runs-on: windows-latest\n <<: *defaults\n"),
3333 );
3334 assert_eq!(
3335 crlf.jobs[0].runs_on.as_ref().unwrap().value,
3336 "windows-latest"
3337 );
3338 assert!(crlf.duplicate_keys.is_empty());
3339
3340 let inside_anchor = must_scan(
3341 "anchor.yml",
3342 "defaults: &defaults\n runs-on: ubuntu-latest\n runs-on: macos-latest\njobs:\n lint:\n <<: *defaults\n runs-on: windows-latest\n",
3343 );
3344 assert_eq!(
3345 inside_anchor.jobs[0].runs_on.as_ref().unwrap().value,
3346 "windows-latest"
3347 );
3348 assert_eq!(inside_anchor.duplicate_keys.len(), 1);
3349 assert_eq!(inside_anchor.duplicate_keys[0].key, "runs-on");
3350 assert_eq!(inside_anchor.duplicate_keys[0].first, 2);
3351 assert_eq!(inside_anchor.duplicate_keys[0].second, 3);
3352 }
3353
3354 #[test]
3355 fn include_chain_that_only_adds_keys_is_one_job() {
3356 let chain = "jobs:\n build:\n strategy:\n matrix:\n include:\n - os: macos-latest\n - os: macos-latest\n npm: \"6\"\n";
3357 let matrix = job_matrix(chain);
3358 assert_eq!(matrix.jobs, 1);
3359 assert!(matrix.jobs_hi.is_none());
3360 assert!(!matrix.at_least);
3361
3362 let crlf_chain = job_matrix(&crlf(chain));
3363 assert_eq!(crlf_chain.jobs, 1);
3364
3365 let flow = job_matrix(
3366 "jobs:\n build:\n strategy:\n matrix:\n include: [{os: macos-latest}, {npm: \"6\", os: macos-latest}]\n",
3367 );
3368 assert_eq!(flow.jobs, 1);
3369
3370 let missing_os = job_matrix(
3371 "jobs:\n build:\n strategy:\n matrix:\n include:\n - os: macos-latest\n - npm: \"6\"\n",
3372 );
3373 assert_eq!(missing_os.jobs, 1);
3374
3375 let third = job_matrix(
3376 "jobs:\n build:\n strategy:\n matrix:\n include:\n - os: macos-latest\n - os: macos-latest\n npm: \"6\"\n - os: macos-latest\n npm: \"6\"\n cache: true\n",
3377 );
3378 assert_eq!(third.jobs, 1);
3379
3380 let capitals = job_matrix(
3381 "jobs:\n build:\n strategy:\n matrix:\n include:\n - os: MacOS-latest\n - os: MacOS-latest\n npm: \"6\"\n",
3382 );
3383 assert_eq!(capitals.jobs, 1);
3384
3385 let other_os = job_matrix(
3386 "jobs:\n build:\n strategy:\n matrix:\n include:\n - os: macos-latest\n - os: windows-latest\n npm: \"6\"\n",
3387 );
3388 assert_eq!(other_os.jobs, 2);
3389
3390 let beside_axis = job_matrix(
3391 "jobs:\n build:\n strategy:\n matrix:\n os: [ubuntu-latest]\n include:\n - os: macos-latest\n - os: macos-latest\n npm: \"6\"\n",
3392 );
3393 assert_eq!(beside_axis.jobs, 2);
3394 assert!(!beside_axis.at_least);
3395
3396 let empty_extra = job_matrix(
3397 "jobs:\n build:\n strategy:\n matrix:\n include:\n - os: macos-latest\n - os: macos-latest\n npm: \"\"\n",
3398 );
3399 assert_eq!(empty_extra.jobs, 1);
3400 }
3401
3402 #[test]
3403 fn dynamic_exclude_reports_lower_and_upper_bounds() {
3404 let plain = "jobs:\n build:\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16, 18]\n exclude: ${{ inputs.rules }}\n";
3405 let matrix = job_matrix(plain);
3406 assert_eq!(matrix.line, 4);
3407 assert_eq!(matrix.jobs, 0);
3408 assert_eq!(matrix.jobs_hi, Some(6));
3409 assert!(!matrix.at_least);
3410
3411 assert_eq!(job_matrix(&crlf(plain)).jobs_hi, Some(6));
3412
3413 let flow = job_matrix(
3414 "jobs:\n build:\n strategy:\n matrix: {node: [14, 16, 18], os: [ubuntu-latest, windows-latest], exclude: \"${{ inputs.rules }}\"}\n",
3415 );
3416 assert_eq!(flow.jobs, 0);
3417 assert_eq!(flow.jobs_hi, Some(6));
3418
3419 let capitals = job_matrix(
3420 "jobs:\n build:\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16, 18]\n exclude: ${{ INPUTS.RULES }}\n",
3421 );
3422 assert_eq!(capitals.jobs, 0);
3423 assert_eq!(capitals.jobs_hi, Some(6));
3424
3425 let spaced = job_matrix(
3426 "jobs:\n build:\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16, 18]\n exclude: ${{ inputs.rules }}\n",
3427 );
3428 assert_eq!(spaced.jobs_hi, Some(6));
3429
3430 let reordered = job_matrix(
3431 "jobs:\n build:\n strategy:\n matrix:\n exclude: ${{ inputs.rules }}\n node: [14, 16, 18]\n os: [ubuntu-latest, windows-latest]\n",
3432 );
3433 assert_eq!(reordered.jobs, 0);
3434 assert_eq!(reordered.jobs_hi, Some(6));
3435
3436 let added = job_matrix(
3437 "jobs:\n build:\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16, 18]\n exclude: ${{ inputs.rules }}\n include:\n - os: macos-latest\n node: 20\n",
3438 );
3439 assert_eq!(added.jobs, 1);
3440 assert_eq!(added.jobs_hi, Some(7));
3441
3442 let chain = job_matrix(
3443 "jobs:\n build:\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16, 18]\n exclude: ${{ inputs.rules }}\n include:\n - os: macos-latest\n node: 20\n - os: macos-latest\n node: 20\n npm: \"6\"\n",
3444 );
3445 assert_eq!(chain.jobs, 1);
3446 assert_eq!(chain.jobs_hi, Some(7));
3447
3448 let expands = job_matrix(
3449 "jobs:\n build:\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16, 18]\n exclude: ${{ inputs.rules }}\n include:\n - os: ubuntu-latest\n node: 14\n npm: \"6\"\n",
3450 );
3451 assert_eq!(expands.jobs, 0);
3452 assert_eq!(expands.jobs_hi, Some(6));
3453
3454 let narrow = job_matrix(
3455 "jobs:\n build:\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16]\n exclude: ${{ inputs.rules }}\n",
3456 );
3457 assert_eq!(narrow.jobs, 0);
3458 assert_eq!(narrow.jobs_hi, Some(4));
3459 assert!(!narrow.at_least);
3460
3461 let absent = job_matrix(
3462 "jobs:\n build:\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16, 18]\n",
3463 );
3464 assert_eq!(absent.jobs, 6);
3465 assert!(absent.jobs_hi.is_none());
3466
3467 let empty_list = job_matrix(
3468 "jobs:\n build:\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16, 18]\n exclude: []\n",
3469 );
3470 assert_eq!(empty_list.jobs, 6);
3471 assert!(empty_list.jobs_hi.is_none());
3472 }
3473
3474 fn axis_block(name: &str, prefix: &str, count: usize) -> String {
3475 let mut text = format!(" {name}:\n");
3476 for index in 0..count {
3477 text.push_str(&format!(" - {prefix}{index}\n"));
3478 }
3479 text
3480 }
3481
3482 #[test]
3483 fn large_matrix_bounds_use_saturating_arithmetic() {
3484 let wide = format!(
3485 "jobs:\n build:\n strategy:\n matrix:\n{}{}",
3486 axis_block("a", "a", 500),
3487 axis_block("b", "b", 401),
3488 );
3489 let matrix = job_matrix(&wide);
3490 assert_eq!(matrix.jobs, 200_500);
3491 assert!(matrix.jobs_hi.is_none());
3492 assert!(!matrix.at_least);
3493
3494 let excluded = format!("{wide} exclude: ${{{{ inputs.rules }}}}\n");
3495 let matrix = job_matrix(&excluded);
3496 assert_eq!(matrix.jobs, 0);
3497 assert_eq!(matrix.jobs_hi, Some(200_500));
3498
3499 let crlf_excluded = job_matrix(&crlf(&excluded));
3500 assert_eq!(crlf_excluded.jobs, 0);
3501 assert_eq!(crlf_excluded.jobs_hi, Some(200_500));
3502
3503 let added = format!(
3504 "{wide} exclude: ${{{{ inputs.rules }}}}\n include:\n - a: other\n - a: other\n npm: \"6\"\n"
3505 );
3506 let matrix = job_matrix(&added);
3507 assert_eq!(matrix.jobs, 1);
3508 assert_eq!(matrix.jobs_hi, Some(200_501));
3509
3510 let chained = format!(
3511 "{wide} include:\n - a: other\n - a: other\n npm: \"6\"\n"
3512 );
3513 let matrix = job_matrix(&chained);
3514 assert_eq!(matrix.jobs, 200_501);
3515 assert!(matrix.jobs_hi.is_none());
3516
3517 let one = format!("{wide} exclude:\n - a: a0\n");
3518 let matrix = job_matrix(&one);
3519 assert_eq!(matrix.jobs, 200_500 - 401);
3520 assert!(matrix.jobs_hi.is_none());
3521
3522 let two = format!("{wide} exclude:\n - a: a0\n - a: a1\n");
3523 let matrix = job_matrix(&two);
3524 assert_eq!(matrix.jobs, 200_500 - 802);
3525 assert_eq!(matrix.jobs_hi, Some(200_500 - 401));
3526
3527 let mut saturated = String::from("jobs:\n build:\n strategy:\n matrix:\n");
3528 for axis in 0..16 {
3529 saturated.push_str(&axis_block(&format!("a{axis}"), "v", 16));
3530 }
3531 let matrix = job_matrix(&saturated);
3532 assert_eq!(matrix.jobs, u64::MAX);
3533 assert!(matrix.jobs_hi.is_none());
3534 assert!(!matrix.at_least);
3535
3536 saturated.push_str(" exclude: ${{ inputs.rules }}\n");
3537 let matrix = job_matrix(&saturated);
3538 assert_eq!(matrix.jobs, 0);
3539 assert_eq!(matrix.jobs_hi, Some(u64::MAX));
3540 }
3541
3542 #[test]
3543 fn duplicate_axis_keeps_the_later_list() {
3544 let matrix = job_matrix(
3545 "jobs:\n build:\n strategy:\n matrix:\n os: [ubuntu-latest, windows-latest]\n node: [14, 16, 18]\n os: [ubuntu-latest]\n",
3546 );
3547 assert_eq!(matrix.jobs, 3);
3548 assert!(matrix.jobs_hi.is_none());
3549 assert!(!matrix.at_least);
3550 }
3551
3552 fn flat_steps(text: &str) -> Vec<(String, usize)> {
3553 let workflow = must_scan("steps.yml", text);
3554 workflow
3555 .jobs
3556 .iter()
3557 .flat_map(|job| job.steps.iter().map(|step| (step.name.clone(), step.line)))
3558 .collect()
3559 }
3560
3561 fn pair(name: &str, line: usize) -> (String, usize) {
3562 (name.to_string(), line)
3563 }
3564
3565 #[test]
3566 fn sample_files_keep_step_names_and_lines() {
3567 let workflows = scan_dir(&sample_dir()).unwrap();
3568 let lint = &by_name(&workflows, "lint.yml").jobs[0];
3569 assert_eq!(
3570 lint.steps_text,
3571 "cargo fmt --check\ncargo clippy -- -D warnings"
3572 );
3573 assert_eq!(
3574 lint.steps
3575 .iter()
3576 .map(|step| (step.name.as_str(), step.line))
3577 .collect::<Vec<_>>(),
3578 vec![
3579 ("actions/checkout@v4", 10),
3580 ("cargo fmt --check", 11),
3581 ("cargo clippy -- -D warnings", 12),
3582 ]
3583 );
3584 let ci = &by_name(&workflows, "ci.yml").jobs[0];
3585 assert_eq!(
3586 ci.steps
3587 .iter()
3588 .map(|step| (step.name.as_str(), step.line))
3589 .collect::<Vec<_>>(),
3590 vec![("actions/checkout@v4", 12), ("cargo test", 13)]
3591 );
3592 let docs = &by_name(&workflows, "docs.yml").jobs[0];
3593 assert_eq!(
3594 docs.steps
3595 .iter()
3596 .map(|step| (step.name.as_str(), step.line))
3597 .collect::<Vec<_>>(),
3598 vec![("actions/checkout@v4", 7), ("cargo doc --no-deps", 8)]
3599 );
3600 let release = &by_name(&workflows, "release.yml").jobs[0];
3601 assert_eq!(
3602 release
3603 .steps
3604 .iter()
3605 .map(|step| (step.name.as_str(), step.line))
3606 .collect::<Vec<_>>(),
3607 vec![("actions/checkout@v4", 10), ("cargo build --release", 11)]
3608 );
3609 }
3610
3611 #[test]
3612 fn block_steps_keep_name_uses_and_run_lines() {
3613 let text = "\
3614jobs:
3615 lint:
3616 steps:
3617 - name: Checkout
3618 uses: actions/checkout@v4
3619 - name: say \"hi\"
3620 run: echo hi
3621 - uses: ./tools/lint
3622 test:
3623 steps:
3624 - run: cargo test
3625";
3626 assert_eq!(
3627 flat_steps(text),
3628 vec![
3629 pair("Checkout", 4),
3630 pair("say \"hi\"", 6),
3631 pair("./tools/lint", 8),
3632 pair("cargo test", 11),
3633 ]
3634 );
3635 let workflow = must_scan("steps.yml", text);
3636 assert_eq!(workflow.jobs[0].steps_text, "echo hi");
3637 assert_eq!(workflow.jobs[1].steps_text, "cargo test");
3638 }
3639
3640 #[test]
3641 fn multiline_run_keeps_the_first_line_and_the_run_key_line() {
3642 let text = "\
3643jobs:
3644 lint:
3645 steps:
3646 - run: |
3647 echo one
3648 echo two
3649 - name: Script
3650 run: |
3651 cargo test -- --test-threads=1
3652 echo done
3653 - name: |
3654 Lint the tree
3655 carefully
3656 run: cargo fmt --check
3657 - run: |
3658 # lint
3659 cargo fmt --check
3660 - run: |
3661
3662 echo one
3663 - run: >
3664 echo one
3665 echo two
3666";
3667 assert_eq!(
3668 flat_steps(text),
3669 vec![
3670 pair("echo one", 4),
3671 pair("Script", 7),
3672 pair("Lint the tree", 11),
3673 pair("# lint", 15),
3674 pair("echo one", 18),
3675 pair("echo one echo two", 21),
3676 ]
3677 );
3678 let workflow = must_scan("steps.yml", text);
3679 assert_eq!(
3680 workflow.jobs[0].steps_text,
3681 "echo one\necho two\ncargo test -- --test-threads=1\necho done\ncargo fmt --check\n# lint\ncargo fmt --check\n\necho one\necho one echo two"
3682 );
3683 }
3684
3685 #[test]
3686 fn step_without_a_name_uses_uses_then_the_first_run_line() {
3687 let text = "\
3688jobs:
3689 lint:
3690 steps:
3691 - uses: actions/checkout@v4
3692 - run: cargo test
3693 - run: cargo test
3694 uses: actions/checkout@v4
3695 - uses: actions/setup-node@v4
3696 run: npm test
3697";
3698 assert_eq!(
3699 flat_steps(text),
3700 vec![
3701 pair("actions/checkout@v4", 4),
3702 pair("cargo test", 5),
3703 pair("actions/checkout@v4", 7),
3704 pair("actions/setup-node@v4", 8),
3705 ]
3706 );
3707 }
3708
3709 #[test]
3710 fn flow_steps_keep_key_lines() {
3711 let inline = "jobs: {lint: {steps: [{name: Checkout, uses: actions/checkout@v4}, {run: cargo test}]}}\n";
3712 assert_eq!(
3713 flat_steps(inline),
3714 vec![pair("Checkout", 1), pair("cargo test", 1)]
3715 );
3716
3717 let split = "\
3718jobs:
3719 lint:
3720 steps: [{name: Lint, run: cargo fmt --check}, {uses: actions/checkout@v4}]
3721";
3722 assert_eq!(
3723 flat_steps(split),
3724 vec![pair("Lint", 3), pair("actions/checkout@v4", 3)]
3725 );
3726
3727 let mixed = "\
3728jobs:
3729 lint:
3730 steps:
3731 - {name: Lint, run: cargo fmt --check}
3732 - run: |
3733 echo one
3734 echo two
3735";
3736 assert_eq!(
3737 flat_steps(mixed),
3738 vec![pair("Lint", 4), pair("echo one", 5)]
3739 );
3740
3741 let quoted = "\
3742jobs:
3743 lint:
3744 steps: [{run: \"echo one\\necho two\"}]
3745";
3746 assert_eq!(flat_steps(quoted), vec![pair("echo one", 3)]);
3747 assert_eq!(
3748 must_scan("steps.yml", quoted).jobs[0].steps_text,
3749 "echo one\necho two"
3750 );
3751 }
3752
3753 #[test]
3754 fn crlf_steps_keep_the_same_lines() {
3755 let text = "\
3756jobs:
3757 lint:
3758 steps:
3759 - name: Checkout
3760 uses: actions/checkout@v4
3761 - run: |
3762 echo one
3763 echo two
3764";
3765 assert_eq!(flat_steps(&crlf(text)), flat_steps(text));
3766 assert_eq!(
3767 flat_steps(&crlf(text)),
3768 vec![pair("Checkout", 4), pair("echo one", 6)]
3769 );
3770 }
3771
3772 #[test]
3773 fn later_keys_choose_the_step_name_and_its_line() {
3774 let ordered = "\
3775jobs:
3776 lint:
3777 steps:
3778 - run: cargo test
3779 name: Run the suite
3780 - run: echo hidden
3781 uses: actions/checkout@v4
3782 - name: First
3783 name: Second
3784 run: cargo test
3785";
3786 assert_eq!(
3787 flat_steps(ordered),
3788 vec![
3789 pair("Run the suite", 5),
3790 pair("actions/checkout@v4", 7),
3791 pair("Second", 9),
3792 ]
3793 );
3794
3795 let capitals = "\
3796jobs:
3797 lint:
3798 STEPS:
3799 - RUN: cargo test
3800 NAME: Suite
3801";
3802 assert_eq!(flat_steps(&crlf(capitals)), vec![pair("Suite", 5)]);
3803 assert_eq!(
3804 must_scan("steps.yml", capitals).jobs[0].steps_text,
3805 "cargo test"
3806 );
3807
3808 let replaced = "\
3809jobs:
3810 lint:
3811 steps:
3812 - run: cargo test
3813 STEPS:
3814 - run: echo later
3815";
3816 assert_eq!(flat_steps(replaced), vec![pair("echo later", 6)]);
3817 }
3818
3819 #[test]
3820 fn empty_and_missing_step_fields_stay_addressable() {
3821 let missing = "\
3822jobs:
3823 lint:
3824 runs-on: ubuntu-latest
3825";
3826 assert!(flat_steps(missing).is_empty());
3827 assert!(flat_steps("jobs:\n lint:\n steps: []\n").is_empty());
3828 assert!(flat_steps("jobs:\n lint:\n steps:\n").is_empty());
3829 assert!(flat_steps("jobs:\n lint:\n steps: null\n").is_empty());
3830 assert!(flat_steps("jobs:\n lint:\n steps: ~\n").is_empty());
3831
3832 let blanks = "\
3833jobs:
3834 lint:
3835 steps:
3836 - name: \"\"
3837 run: cargo test
3838 - name: \" \"
3839 uses: actions/setup-node@v4
3840 - name:
3841 run: echo hi
3842 - name: null
3843 run: echo null-name
3844 - name: ~
3845 uses: actions/checkout@v4
3846 - id: setup
3847 -
3848 - {}
3849 - run: echo kept
3850";
3851 assert_eq!(
3852 flat_steps(blanks),
3853 vec![
3854 pair("cargo test", 5),
3855 pair("actions/setup-node@v4", 7),
3856 pair("echo hi", 9),
3857 pair("echo null-name", 11),
3858 pair("actions/checkout@v4", 13),
3859 pair("", 14),
3860 pair("", 16),
3861 pair("", 16),
3862 pair("echo kept", 17),
3863 ]
3864 );
3865
3866 let scalar = "\
3867jobs:
3868 lint:
3869 steps: cargo test
3870";
3871 assert_eq!(flat_steps(scalar), vec![pair("cargo test", 3)]);
3872
3873 let mapping = "\
3874jobs:
3875 lint:
3876 steps:
3877 name: Only
3878 run: cargo test
3879";
3880 assert_eq!(flat_steps(mapping), vec![pair("Only", 4)]);
3881
3882 let nested = "\
3883jobs:
3884 lint:
3885 steps:
3886 -
3887 - run: echo inner
3888";
3889 assert_eq!(flat_steps(nested), vec![pair("echo inner", 5)]);
3890 }
3891
3892 #[test]
3893 fn capital_step_keys_keep_lines() {
3894 let text = "\
3895jobs:
3896 lint:
3897 Steps:
3898 - NAME: Checkout
3899 USES: actions/checkout@v4
3900 - RUN: |
3901 echo one
3902 echo two
3903";
3904 assert_eq!(
3905 flat_steps(text),
3906 vec![pair("Checkout", 4), pair("echo one", 6)]
3907 );
3908 assert_eq!(
3909 must_scan("steps.yml", text).jobs[0].steps_text,
3910 "echo one\necho two"
3911 );
3912 assert_eq!(flat_steps(&crlf(text)), flat_steps(text));
3913 }
3914
3915 #[test]
3916 fn anchors_keep_the_step_source_line() {
3917 let inherited = "\
3918defaults: &defaults
3919 steps:
3920 - run: cargo test
3921jobs:
3922 test:
3923 <<: *defaults
3924";
3925 assert_eq!(flat_steps(inherited), vec![pair("cargo test", 3)]);
3926
3927 let overridden = "\
3928defaults: &defaults
3929 steps:
3930 - run: cargo test
3931jobs:
3932 test:
3933 <<: *defaults
3934 steps:
3935 - name: Real
3936 run: cargo fmt --check
3937";
3938 assert_eq!(flat_steps(overridden), vec![pair("Real", 8)]);
3939
3940 let merged_step = "\
3941checkout: &checkout
3942 name: Checkout
3943 uses: actions/checkout@v4
3944jobs:
3945 test:
3946 steps:
3947 - <<: *checkout
3948 - run: cargo test
3949";
3950 assert_eq!(
3951 flat_steps(merged_step),
3952 vec![pair("Checkout", 2), pair("cargo test", 8)]
3953 );
3954
3955 let bom = "\u{feff}jobs:\n lint:\n steps:\n - run: cargo test\n";
3956 assert_eq!(flat_steps(bom), vec![pair("cargo test", 4)]);
3957 }
3958
3959 #[test]
3960 fn local_action_uses_are_recorded_on_steps() {
3961 let text = "\
3962jobs:
3963 build:
3964 steps:
3965 - uses: ./.github/actions/lint
3966 - name: Checkout
3967 uses: actions/checkout@v4
3968 - uses: ./.github/workflows/lint.yml
3969 - uses: docker://alpine
3970 - uses: ./.github/actions/../actions/./lint
3971 - uses: ./.github\\actions\\lint
3972 - uses: ./.github/actions/lint/action.yml
3973 - uses: ./
3974 - uses: ./../outside
3975 - uses: .\\.github\\workflows\\lint.yml
3976 - USES: \"./.github/actions/lint\"
3977";
3978 let job = must_scan("repo/.github/workflows/ci.yml", text)
3979 .jobs
3980 .remove(0);
3981 let recorded: Vec<Option<(&str, &str, usize)>> = job
3982 .steps
3983 .iter()
3984 .map(|step| {
3985 step.local_action
3986 .as_ref()
3987 .map(|action| (action.spec.as_str(), action.relative.as_str(), action.line))
3988 })
3989 .collect();
3990 assert_eq!(
3991 recorded,
3992 vec![
3993 Some(("./.github/actions/lint", ".github/actions/lint", 4)),
3994 None,
3995 None,
3996 None,
3997 Some((
3998 "./.github/actions/../actions/./lint",
3999 ".github/actions/lint",
4000 9
4001 )),
4002 Some(("./.github\\actions\\lint", ".github/actions/lint", 10)),
4003 Some((
4004 "./.github/actions/lint/action.yml",
4005 ".github/actions/lint",
4006 11
4007 )),
4008 Some(("./", "", 12)),
4009 None,
4010 None,
4011 Some(("./.github/actions/lint", ".github/actions/lint", 15)),
4012 ]
4013 );
4014 assert_eq!(job.steps[1].name, "Checkout");
4015 assert_eq!(job.steps[10].line, 15);
4016 }
4017
4018 #[test]
4019 fn parse_action_reads_composite_steps_and_ignores_other_runners() {
4020 let composite = parse_action(
4021 Path::new("action.yml"),
4022 "name: Lint\ndescription: Format\nruns:\n using: composite\n steps:\n - shell: bash\n run: cargo fmt --check\n",
4023 )
4024 .unwrap();
4025 assert_eq!(composite.using, "composite");
4026 assert_eq!(composite.steps.len(), 1);
4027 assert_eq!(composite.steps[0].run_text, "cargo fmt --check");
4028 assert_eq!(composite.steps[0].run_line, Some(7));
4029
4030 let reordered = parse_action(
4031 Path::new("action.yml"),
4032 "RUNS:\n STEPS:\n - name: prettier\n shell: bash\n run: echo ok\n USING: \" Composite \"\n",
4033 )
4034 .unwrap();
4035 assert_eq!(reordered.using, "Composite");
4036 assert_eq!(reordered.steps[0].name, "prettier");
4037 assert_eq!(reordered.steps[0].name_line, Some(3));
4038 assert_eq!(reordered.steps[0].run_line, Some(5));
4039
4040 let flow = parse_action(
4041 Path::new("action.yaml"),
4042 "runs: {using: composite, steps: [{run: cargo clippy -- -D warnings, shell: bash}]}\n",
4043 )
4044 .unwrap();
4045 assert_eq!(flow.using, "composite");
4046 assert_eq!(flow.steps[0].run_line, Some(1));
4047 assert!(flow.steps[0].run_text.contains("clippy"));
4048
4049 let crlf = parse_action(
4050 Path::new("action.yml"),
4051 "runs:\r\n using: composite\r\n steps:\r\n - run: cargo fmt --check\r\n",
4052 )
4053 .unwrap();
4054 assert_eq!(crlf.steps[0].run_line, Some(4));
4055
4056 let bom = parse_action(
4057 Path::new("action.yml"),
4058 "\u{feff}runs:\n using: node20\n main: index.js\n",
4059 )
4060 .unwrap();
4061 assert_eq!(bom.using, "node20");
4062 assert!(bom.steps.is_empty());
4063
4064 let docker = parse_action(
4065 Path::new("action.yml"),
4066 "runs:\n using: docker\n image: Dockerfile\n steps:\n - run: cargo fmt --check\n",
4067 )
4068 .unwrap();
4069 assert_eq!(docker.using, "docker");
4070 assert_eq!(docker.steps.len(), 1);
4071
4072 let empty = parse_action(Path::new("action.yml"), "").unwrap();
4073 assert!(empty.using.is_empty());
4074 assert!(empty.steps.is_empty());
4075
4076 let broken = parse_action(Path::new("action.yml"), "runs: [\nSECRET\n");
4077 let err = broken.expect_err("invalid yaml");
4078 assert!(err.to_string().contains("invalid YAML"));
4079 assert!(!err.to_string().contains("SECRET"));
4080
4081 let nested = parse_action(
4082 Path::new("action.yml"),
4083 "runs:\n using: composite\n steps:\n - uses: ./.github/actions/inner\n",
4084 )
4085 .unwrap();
4086 let inner = nested.steps[0].local_action.as_ref().unwrap();
4087 assert_eq!(inner.relative, ".github/actions/inner");
4088 assert_eq!(inner.line, 4);
4089 }
4090}