Skip to main content

ci_bill/
workflow.rs

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/// One workflow file.
11///
12/// Line numbers are 1-based positions of keys in the file.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Workflow {
15    pub path: PathBuf,
16    /// Line of the top-level `on:` key, when present.
17    pub on_line: Option<usize>,
18    pub has_pull_request: bool,
19    /// `pull_request_target` is present under `on:`.
20    pub has_pull_request_target: bool,
21    /// Top-level `concurrency.cancel-in-progress` cancels in-progress runs.
22    /// Literal `true` and `github.event_name == 'pull_request'` both count.
23    pub cancel_in_progress: bool,
24    /// Line of the top-level `concurrency:` key. The key match ignores ASCII case.
25    pub concurrency_line: Option<usize>,
26    pub jobs: Vec<Job>,
27    /// Repeated mapping keys, in document order. The later value won.
28    pub duplicate_keys: Vec<DuplicateKey>,
29}
30
31/// One job under top-level `jobs:`.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct Job {
34    pub name: String,
35    /// Line of the job key.
36    pub line: usize,
37    /// Job-level `name:` value, when it is written separately from the key.
38    pub display_name: Option<String>,
39    pub runs_on: Option<RunsOn>,
40    pub has_timeout_minutes: bool,
41    /// Text of `run` steps, one step per line. Multiline scripts keep their lines.
42    pub steps_text: String,
43    /// Steps in document order, for later linking.
44    pub steps: Vec<Step>,
45    /// `strategy.matrix`, when the job has one.
46    pub matrix: Option<Matrix>,
47    /// Job-level `concurrency.cancel-in-progress` cancels in-progress runs.
48    pub cancel_in_progress: bool,
49    /// Line of this job's `concurrency:` key. The key match ignores ASCII case.
50    pub concurrency_line: Option<usize>,
51    /// `uses:` calling a local or remote reusable workflow.
52    pub uses: Option<ReusableCall>,
53    /// Job-level `uses:` that is not a reusable workflow call.
54    pub invalid_uses: Option<InvalidUses>,
55}
56
57/// One step under `jobs.<id>.steps`.
58///
59/// `name` is the `name` scalar when it has text. Otherwise it is the `uses`
60/// scalar, and otherwise the first non-empty line of `run`. `line` is the
61/// 1-based line of the key that supplied `name`. Those keys match without
62/// ASCII case, and the later key wins. Block and flow styles both count.
63/// A step with none of those keys keeps an empty name and the line of its
64/// first key, or the line of the step itself.
65///
66/// `local_action` is set when `uses` is a `./` path to an action in this
67/// repository. A reusable workflow path and a remote action stay empty.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct Step {
70    pub name: String,
71    pub line: usize,
72    /// `uses: ./path` for a local action directory, when present.
73    pub local_action: Option<LocalAction>,
74}
75
76/// A step calling `uses: ./path` in this repository.
77///
78/// The path is relative to the repository root. `./`, `./action.yml`, and
79/// `./pad/action.yaml` all name the directory that holds the action file.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct LocalAction {
82    /// Line of the `uses:` key.
83    pub line: usize,
84    /// Trimmed scalar, for example `./.github/actions/lint`.
85    pub spec: String,
86    /// Directory relative to the repository root, `/`-separated.
87    /// Empty means the repository root (`uses: ./`).
88    pub relative: String,
89}
90
91/// `runs` from an action file.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct ParsedAction {
94    pub path: PathBuf,
95    /// Trimmed `runs.using` text. Empty when the key is missing.
96    pub using: String,
97    pub steps: Vec<ActionStep>,
98}
99
100/// One step under `runs.steps` of an action file.
101#[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/// A job calling a reusable workflow.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct ReusableCall {
113    /// Line of the `uses:` key.
114    pub line: usize,
115    /// Scalar text, for example `./.github/workflows/lint.yml`.
116    pub spec: String,
117    /// File name the call asks for, for example `lint.yml`.
118    pub file_name: String,
119    /// Local scan or a remote repository pinned to a ref.
120    pub source: ReusableSource,
121}
122
123/// Where the called workflow file lives.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum ReusableSource {
126    /// `./.github/workflows/<file>.yml` in the scanned directory.
127    Local,
128    /// `owner/repo/.github/workflows/<file>.yml@ref` in the runs cache.
129    Remote(RemoteWorkflow),
130}
131
132/// A reusable workflow in another repository, pinned to a tag, branch, or SHA.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct RemoteWorkflow {
135    pub owner: String,
136    pub repo: String,
137    /// Path inside the repository, for example `.github/workflows/lint.yml`.
138    pub workflow_path: String,
139    /// Tag, branch, or commit SHA. A branch may contain `/`.
140    pub reference: String,
141}
142
143impl RemoteWorkflow {
144    /// Relative runs-cache path `<owner>/<repo>/<workflow_path>@<reference>`.
145    ///
146    /// A reference that contains `/` continues as further path components.
147    /// `lint.yml` at ref `release/1.2` is `lint.yml@release/1.2`.
148    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/// Job-level `uses:` that is not a local or remote reusable workflow.
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct InvalidUses {
173    /// Line of the `uses:` key.
174    pub line: usize,
175    /// Quoted scalar, or `a list` / `a mapping`.
176    pub detail: String,
177}
178
179/// `runs-on` for a job.
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct RunsOn {
182    /// Line of the `runs-on` key.
183    pub line: usize,
184    /// Runner text, for example `ubuntu-latest` or `[self-hosted, macos]`.
185    pub value: String,
186    pub labels: Vec<String>,
187}
188
189/// How many jobs one `strategy.matrix` starts.
190///
191/// `jobs` is the expanded combination count. When an axis value is a
192/// `${{ ... }}` expression, `at_least` is set and `jobs` is the product of the
193/// other axes (each expression axis contributes at least one value).
194/// A dynamic `exclude` stores a finite upper bound in `jobs_hi` and the lower
195/// bound in `jobs`.
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub struct Matrix {
198    /// Line of the `matrix:` key.
199    pub line: usize,
200    /// Exact count, or the lower bound when `at_least` or `jobs_hi` is set.
201    pub jobs: u64,
202    pub at_least: bool,
203    /// Finite upper bound, present only when it is greater than `jobs`.
204    pub jobs_hi: Option<u64>,
205    /// Literal axis and include values, used to resolve `matrix.<name>` in `runs-on`.
206    pub axes: Vec<MatrixAxis>,
207}
208
209/// One matrix axis, including keys that appear only under `include`.
210#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct MatrixAxis {
212    pub name: String,
213    /// Line of the axis key. Include-only keys use the first include key line.
214    pub line: usize,
215    /// Each entry is one literal label set. Expressions are left out.
216    pub values: Vec<Vec<String>>,
217}
218
219/// One repeated mapping key. The later line wins.
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct DuplicateKey {
222    pub key: String,
223    /// Line of the earlier occurrence.
224    pub first: usize,
225    /// Line of the later occurrence, whose value is kept.
226    pub second: usize,
227}
228
229/// The workflow directory or a workflow file could not be read.
230#[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
278/// Read `*.yml` and `*.yaml` files directly in `dir`, in path order.
279///
280/// Nested directories are not walked. An unreadable directory or file is an
281/// error. Invalid YAML is an error that names the file and the line.
282pub 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
302/// Scan one workflow document.
303///
304/// Invalid YAML returns an error with the 1-based line and without the
305/// document text. Every other input returns a workflow.
306pub 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        /// 1-based line of the sequence start.
354        line: usize,
355        items: Vec<Node>,
356    },
357    Mapping {
358        /// 1-based line of the mapping start.
359        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
372/// Upper bound on the total number of nodes a document may expand to, counting
373/// every node reached through an alias as many times as it is aliased. This
374/// caps "billion laughs" style anchor/alias chains: expanding past the budget
375/// is a normal parse error rather than unbounded cloning.
376const 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
592/// Last explicit value wins. `<<` fills only keys that were not written explicitly,
593/// so an explicit key still wins whether it is written before or after the merge.
594fn 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
686/// Later key wins. `steps`, `name`, `uses`, and `run` match without ASCII case.
687fn 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
767/// Literal `true`, or `github.event_name == 'pull_request'` (either quote, any
768/// spacing, optional `${{ }}`). `pull_request_target` uses the same comparison.
769/// An `||` chain counts when any clause does.
770fn 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
1026/// `./.github/workflows/<file>.yml` or `.yaml`, ignoring ASCII case on the
1027/// directory names and the extension. `..` and `.` are folded first.
1028fn 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
1066/// `owner/repo/.github/workflows/<file>.yml@ref`, ref a tag, branch, or SHA.
1067///
1068/// Directory names and the extension are compared without ASCII case. `..` is
1069/// rejected. The stored spec is the trimmed scalar, not a rewritten path.
1070fn 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
1389/// `./path` to a local action. Reusable workflow calls and remote actions are not.
1390fn 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
1414/// Directory relative to the repository root.
1415///
1416/// `./` and `./action.yml` are the repository root. `..` that leaves the
1417/// root is rejected. An empty string means the root.
1418fn 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
1443/// `.github/workflows/<file>.yml` is a reusable workflow, not an action directory.
1444fn 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
1463/// Repository root for a file at `<root>/.github/workflows/<file>`.
1464pub 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
1482/// `action.yml` or `action.yaml` inside `root/relative`.
1483///
1484/// An empty `relative` is the repository root. Exact `action.yml` wins, then
1485/// exact `action.yaml`, then a case-insensitive match. `Ok(None)` when the
1486/// directory or the action file is absent.
1487pub 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
1538/// Read one action file. Invalid YAML names the file and the line.
1539pub 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
1544/// Parse action YAML. Invalid YAML names the file and the line.
1545pub 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
1662/// Cross products larger than this are counted from the axis lengths.
1663/// Building every combination would allocate without bound.
1664const 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    /// `exclude: ${{ ... }}` or an expression item. The concrete rows still apply.
1675    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
1798/// One label set per list item. A nested list is one multi-label runner.
1799fn 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
1889/// The whole scalar is one `${{ ... }}` expression, so its length is not in the file.
1890fn 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
1919/// `(jobs, jobs_hi, at_least)`. `jobs_hi` is set only for a finite range.
1920fn 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
1988/// Above the combination cap the bounds use saturating products and spans.
1989/// Combinations are not allocated.
1990fn 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    // An include that fits the original space adds a job only when exclude
2007    // removed its target. That slack keeps the upper bound above the true count.
2008    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
2055/// How many original combinations one exclude row removes.
2056///
2057/// The span is `product / product_of_named_axis_lengths` when every named
2058/// value exists. Overlapping rows are not subtracted twice; callers turn the
2059/// spans into a lower and an upper bound.
2060fn 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    // A partial row matches. A key that belongs to an expression axis might
2131    // match, so it does not keep the combination in the lower bound.
2132    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
2154/// Include rows that add a job on top of `base`.
2155///
2156/// A row that only adds keys to an existing combination, including one created
2157/// by an earlier include, does not add a job. A row that sets an original axis
2158/// the combination does not have is a new job (it does not merge into that
2159/// include-created combination).
2160fn 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
2225/// An include-created combination accepts a later row when shared keys match
2226/// and every original axis in the row is already present with the same value.
2227fn 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}