Skip to main content

ci_bill/
fix_diff.rs

1//! Unified diff for the top finding this tool knows how to fix.
2//!
3//! F1 changes `runs-on` to `ubuntu-latest`. F2 adds `timeout-minutes`. F3
4//! adds a `concurrency` block with `cancel-in-progress: true`. The finding
5//! list is already sorted by savings, highest first, so the first finding of
6//! one of these kinds is the one priced highest.
7
8use std::fmt::Write as _;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use crate::findings::{Finding, FindingKind};
13
14const CONTEXT: usize = 3;
15const DEFAULT_TIMEOUT_MINUTES: &str = "30";
16
17/// Diff for the highest-savings finding this module can fix, or `None` when
18/// no such finding is present, or its file can no longer be read.
19pub fn render(found: &[Finding], workflows_dir: Option<&Path>) -> Option<String> {
20    let finding = found.iter().find(|item| {
21        matches!(
22            item.kind,
23            FindingKind::LintOnRunner
24                | FindingKind::MissingTimeout
25                | FindingKind::PullRequestConcurrency
26        )
27    })?;
28    match finding.kind {
29        FindingKind::LintOnRunner => lint_diff(finding, workflows_dir),
30        FindingKind::MissingTimeout => timeout_diff(finding),
31        FindingKind::PullRequestConcurrency => concurrency_diff(finding),
32        _ => None,
33    }
34}
35
36/// The file and line this finding's fix must change.
37///
38/// Most findings already point `path`/`line` at the real `runs-on`, job, or
39/// `on:` key. A lint finding raised through a local composite action instead
40/// points at the action step, and keeps the job's `runs-on` line in `anchor`.
41/// That line lives in the calling workflow file, named in `workflow_path`
42/// (`.github/workflows/<file>`), found under `workflows_dir`.
43fn resolve_target(finding: &Finding, workflows_dir: Option<&Path>) -> Option<(PathBuf, usize)> {
44    match finding.anchor {
45        Some(anchor_line) => {
46            let dir = workflows_dir?;
47            let logical = finding.workflow_path.as_deref()?;
48            let file_name = logical.rsplit('/').next().filter(|name| !name.is_empty())?;
49            Some((dir.join(file_name), anchor_line))
50        }
51        None => Some((finding.path.clone(), finding.line)),
52    }
53}
54
55fn lint_diff(finding: &Finding, workflows_dir: Option<&Path>) -> Option<String> {
56    let (path, line) = resolve_target(finding, workflows_dir)?;
57    let text = fs::read_to_string(&path).ok()?;
58    let lines = split_lines(&text);
59    let key_idx = line.checked_sub(1)?;
60    if key_idx >= lines.len() {
61        return None;
62    }
63    let end_idx = block_extent(&lines, key_idx);
64    if end_idx == key_idx + 1 {
65        // The value lives on this one physical line: a plain block scalar,
66        // or a scalar inside a flow mapping. Replace just the value so a
67        // trailing comma, closing brace, or comment on the same line (flow
68        // siblings, or a block comment) survives untouched.
69        let bare = strip_cr(lines[key_idx].0);
70        let (value_start, value_end) = find_value_span(bare, "runs-on")?;
71        let mut new_line = String::with_capacity(bare.len() + "ubuntu-latest".len());
72        new_line.push_str(&bare[..value_start]);
73        new_line.push_str("ubuntu-latest");
74        new_line.push_str(&bare[value_end..]);
75        return Some(build_diff(
76            &path,
77            &lines,
78            text.contains("\r\n"),
79            key_idx,
80            end_idx,
81            &[new_line],
82        ));
83    }
84    let indent = " ".repeat(leading_spaces(lines[key_idx].0));
85    let new_line = format!("{indent}runs-on: ubuntu-latest");
86    Some(build_diff(
87        &path,
88        &lines,
89        text.contains("\r\n"),
90        key_idx,
91        end_idx,
92        &[new_line],
93    ))
94}
95
96fn timeout_diff(finding: &Finding) -> Option<String> {
97    let path = finding.path.clone();
98    let text = fs::read_to_string(&path).ok()?;
99    let lines = split_lines(&text);
100    let key_idx = finding.line.checked_sub(1)?;
101    if key_idx >= lines.len() {
102        return None;
103    }
104    if let Some(brace_end) = flow_job_brace(&path, &text, lines[key_idx].0, finding.line) {
105        // The job's value is a flow mapping on this line: add `timeout-minutes`
106        // as a new flow entry right after its opening brace. Appending an
107        // indented line below it, as the block case does, is not valid YAML
108        // once the job's whole value is already a complete flow node.
109        let bare = strip_cr(lines[key_idx].0);
110        let mut new_line = String::with_capacity(bare.len() + 24);
111        new_line.push_str(&bare[..brace_end]);
112        let _ = write!(new_line, " timeout-minutes: {DEFAULT_TIMEOUT_MINUTES},");
113        new_line.push_str(&bare[brace_end..]);
114        return Some(build_diff(
115            &path,
116            &lines,
117            text.contains("\r\n"),
118            key_idx,
119            key_idx + 1,
120            &[new_line],
121        ));
122    }
123    let job_indent = leading_spaces(lines[key_idx].0);
124    let child_indent = lines[key_idx + 1..]
125        .iter()
126        .map(|(content, _)| *content)
127        .find(|content| !content.trim().is_empty())
128        .map(leading_spaces)
129        .filter(|indent| *indent > job_indent)
130        .unwrap_or(job_indent + 2);
131    let indent = " ".repeat(child_indent);
132    let new_line = format!("{indent}timeout-minutes: {DEFAULT_TIMEOUT_MINUTES}");
133    Some(build_diff(
134        &path,
135        &lines,
136        text.contains("\r\n"),
137        key_idx + 1,
138        key_idx + 1,
139        &[new_line],
140    ))
141}
142
143fn strip_cr(line: &str) -> &str {
144    line.strip_suffix('\r').unwrap_or(line)
145}
146
147/// Index right after `key`'s occurrence in `line`, skipping one matching
148/// closing quote when `key` was itself quoted (`"runs-on"`, `'runs-on'`).
149fn skip_closing_quote(line: &str, key_pos: usize, after_key: usize) -> usize {
150    let bytes = line.as_bytes();
151    let opening_quote = key_pos
152        .checked_sub(1)
153        .and_then(|idx| bytes.get(idx))
154        .copied()
155        .filter(|ch| *ch == b'"' || *ch == b'\'');
156    match (opening_quote, bytes.get(after_key)) {
157        (Some(open), Some(close)) if open == *close => after_key + 1,
158        _ => after_key,
159    }
160}
161
162/// Byte range of `key`'s value on `line`: a bare or quoted key, any spaces
163/// or tabs after the colon, then a quoted or bare scalar value. A bare value
164/// ends at a flow delimiter (`,`, `}`, `]`), a comment, or the end of line;
165/// trailing whitespace before that point is not part of the value.
166fn find_value_span(line: &str, key: &str) -> Option<(usize, usize)> {
167    let key_pos = line.find(key)?;
168    let after_key = skip_closing_quote(line, key_pos, key_pos + key.len());
169    let colon = line[after_key..].find(':')? + after_key;
170    let bytes = line.as_bytes();
171    let mut start = colon + 1;
172    while bytes
173        .get(start)
174        .is_some_and(|ch| *ch == b' ' || *ch == b'\t')
175    {
176        start += 1;
177    }
178    let end = match bytes.get(start) {
179        Some(b'"') => find_quoted_end(bytes, start, b'"', true)?,
180        Some(b'\'') => find_quoted_end(bytes, start, b'\'', false)?,
181        _ => find_bare_end(bytes, start),
182    };
183    Some((start, end))
184}
185
186/// Exclusive end of a quoted scalar starting at `start` (the opening quote).
187/// Double-quoted scalars use `\"` to escape the quote; single-quoted
188/// scalars double it (`''`).
189fn find_quoted_end(
190    bytes: &[u8],
191    start: usize,
192    quote: u8,
193    backslash_escapes: bool,
194) -> Option<usize> {
195    let mut idx = start + 1;
196    while idx < bytes.len() {
197        if backslash_escapes && bytes[idx] == b'\\' {
198            idx += 2;
199            continue;
200        }
201        if bytes[idx] == quote {
202            if !backslash_escapes && bytes.get(idx + 1) == Some(&quote) {
203                idx += 2;
204                continue;
205            }
206            return Some(idx + 1);
207        }
208        idx += 1;
209    }
210    None
211}
212
213/// Exclusive end of a bare scalar starting at `start`: up to (not including)
214/// a flow delimiter, a whitespace-led comment, or the end of line, with
215/// trailing whitespace trimmed.
216fn find_bare_end(bytes: &[u8], start: usize) -> usize {
217    let mut idx = start;
218    while idx < bytes.len() {
219        match bytes[idx] {
220            b',' | b'}' | b']' => break,
221            b'#' if idx > start && matches!(bytes[idx - 1], b' ' | b'\t') => break,
222            _ => idx += 1,
223        }
224    }
225    let mut end = idx;
226    while end > start && matches!(bytes[end - 1], b' ' | b'\t') {
227        end -= 1;
228    }
229    end
230}
231
232/// When `job_line`'s job value is a flow mapping (`name: { ... }`), the
233/// index right after that mapping's opening brace. `None` for a block-style
234/// job, so the caller falls back to inserting an indented line below it.
235fn flow_job_brace(path: &Path, text: &str, line: &str, job_line: usize) -> Option<usize> {
236    let workflow = crate::workflow::scan_yaml(path, text).ok()?;
237    let job = workflow.jobs.iter().find(|job| job.line == job_line)?;
238    let bare = strip_cr(line);
239    let key_pos = bare.find(job.name.as_str())?;
240    let after_key = skip_closing_quote(bare, key_pos, key_pos + job.name.len());
241    let colon = bare[after_key..].find(':')? + after_key;
242    let bytes = bare.as_bytes();
243    let mut idx = colon + 1;
244    while bytes.get(idx).is_some_and(|ch| *ch == b' ' || *ch == b'\t') {
245        idx += 1;
246    }
247    if bytes.get(idx) == Some(&b'{') {
248        Some(idx + 1)
249    } else {
250        None
251    }
252}
253
254fn concurrency_diff(finding: &Finding) -> Option<String> {
255    let path = finding.path.clone();
256    let text = fs::read_to_string(&path).ok()?;
257    let lines = split_lines(&text);
258    let key_idx = finding.line.checked_sub(1)?;
259    if key_idx >= lines.len() {
260        return None;
261    }
262    let end_idx = block_extent(&lines, key_idx);
263    let new_lines = [
264        "concurrency:".to_string(),
265        "  group: ${{ github.workflow }}-${{ github.ref }}".to_string(),
266        "  cancel-in-progress: true".to_string(),
267    ];
268    Some(build_diff(
269        &path,
270        &lines,
271        text.contains("\r\n"),
272        end_idx,
273        end_idx,
274        &new_lines,
275    ))
276}
277
278/// Split into lines without the trailing `\n`. A line kept from a `\r\n`
279/// ending keeps its `\r`, so a context or removed line reproduces the exact
280/// original bytes. The bool is whether the line had a trailing `\n` in the
281/// file; the last line of a file saved without one carries `false`.
282fn split_lines(text: &str) -> Vec<(&str, bool)> {
283    let mut out = Vec::new();
284    let mut rest = text;
285    while !rest.is_empty() {
286        match rest.find('\n') {
287            Some(idx) => {
288                out.push((&rest[..idx], true));
289                rest = &rest[idx + 1..];
290            }
291            None => {
292                out.push((rest, false));
293                rest = "";
294            }
295        }
296    }
297    out
298}
299
300fn leading_spaces(line: &str) -> usize {
301    line.chars().take_while(|ch| *ch == ' ').count()
302}
303
304/// Exclusive end of the value that starts right after `key_idx`: every line
305/// more indented than the key, with blank lines bridging over to further
306/// indented content. Returns `key_idx + 1` when the key's value is written
307/// on its own line (a scalar, a flow list, or a flow map).
308fn block_extent(lines: &[(&str, bool)], key_idx: usize) -> usize {
309    let key_indent = leading_spaces(lines[key_idx].0);
310    let mut end = key_idx + 1;
311    while end < lines.len() {
312        let content = lines[end].0;
313        if content.trim().is_empty() || leading_spaces(content) > key_indent {
314            end += 1;
315        } else {
316            break;
317        }
318    }
319    end
320}
321
322/// `path` with `/` separators, as `git apply` expects in `--- a/`/`+++ b/`
323/// headers. On Windows a `\` in those headers is an invalid path for git.
324fn diff_path(path: &Path) -> String {
325    let text = path.to_string_lossy();
326    if std::path::MAIN_SEPARATOR == '\\' {
327        text.replace('\\', "/")
328    } else {
329        text.into_owned()
330    }
331}
332
333#[allow(clippy::too_many_arguments)]
334fn build_diff(
335    path: &Path,
336    lines: &[(&str, bool)],
337    crlf: bool,
338    start: usize,
339    end: usize,
340    new_lines: &[String],
341) -> String {
342    let ctx_start = start.saturating_sub(CONTEXT);
343    let ctx_end = (end + CONTEXT).min(lines.len());
344    let old_count = ctx_end - ctx_start;
345    let new_count = (start - ctx_start) + new_lines.len() + (ctx_end - end);
346    let display = diff_path(path);
347    let mut out = String::new();
348    let _ = writeln!(out, "--- a/{display}");
349    let _ = writeln!(out, "+++ b/{display}");
350    let _ = writeln!(
351        out,
352        "@@ -{},{} +{},{} @@",
353        ctx_start + 1,
354        old_count,
355        ctx_start + 1,
356        new_count
357    );
358    for line in &lines[ctx_start..start] {
359        emit(&mut out, ' ', *line);
360    }
361    for line in &lines[start..end] {
362        emit(&mut out, '-', *line);
363    }
364    let suffix = if crlf { "\r" } else { "" };
365    for content in new_lines {
366        let _ = writeln!(out, "+{content}{suffix}");
367    }
368    for line in &lines[end..ctx_end] {
369        emit(&mut out, ' ', *line);
370    }
371    out
372}
373
374fn emit(out: &mut String, marker: char, (content, has_newline): (&str, bool)) {
375    let _ = writeln!(out, "{marker}{content}");
376    if !has_newline {
377        out.push_str("\\ No newline at end of file\n");
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384    use crate::findings::Finding;
385    use std::fs;
386
387    fn scratch(name: &str) -> PathBuf {
388        let base = std::env::var_os("CARGO_TARGET_TMPDIR")
389            .map(PathBuf::from)
390            .unwrap_or_else(std::env::temp_dir);
391        let path = base.join("ci-bill-fix-diff").join(name);
392        let _ = fs::remove_dir_all(&path);
393        fs::create_dir_all(&path).expect("scratch dir");
394        path
395    }
396
397    fn write(dir: &Path, name: &str, text: &str) -> PathBuf {
398        let path = dir.join(name);
399        fs::write(&path, text).expect("write fixture");
400        path
401    }
402
403    fn finding(path: &Path, line: usize, kind: FindingKind, message: &str) -> Finding {
404        Finding::record(path, line, kind, message)
405    }
406
407    const LINT_YML: &str = "name: Lint\non:\n  push:\n  pull_request:\n\njobs:\n  lint:\n    runs-on: macos-latest\n    steps:\n      - uses: actions/checkout@v4\n      - run: cargo fmt --check\n";
408
409    #[test]
410    fn lint_on_runner_changes_runs_on_to_ubuntu_latest() {
411        let dir = scratch("lint");
412        let path = write(&dir, "lint.yml", LINT_YML);
413        let found = [finding(
414            &path,
415            8,
416            FindingKind::LintOnRunner,
417            "lint on macOS",
418        )];
419        let diff = render(&found, None).expect("diff");
420        assert!(diff.contains("-    runs-on: macos-latest\n"), "{diff}");
421        assert!(diff.contains("+    runs-on: ubuntu-latest\n"), "{diff}");
422        assert!(diff.starts_with(&format!("--- a/{}\n", diff_path(&path))));
423    }
424
425    #[test]
426    fn diff_headers_use_forward_slashes() {
427        assert_eq!(
428            diff_path(Path::new(".github/workflows/ci.yml")),
429            ".github/workflows/ci.yml"
430        );
431        #[cfg(windows)]
432        assert_eq!(
433            diff_path(Path::new(".github\\workflows\\ci.yml")),
434            ".github/workflows/ci.yml"
435        );
436    }
437
438    #[test]
439    fn missing_timeout_inserts_timeout_minutes_under_the_job() {
440        let dir = scratch("timeout");
441        let path = write(&dir, "docs.yml", LINT_YML);
442        let found = [finding(
443            &path,
444            7,
445            FindingKind::MissingTimeout,
446            "default timeout is 360 minutes",
447        )];
448        let diff = render(&found, None).expect("diff");
449        assert!(diff.contains("+    timeout-minutes: 30\n"), "{diff}");
450        assert!(
451            !diff
452                .lines()
453                .any(|line| line.starts_with('-') && !line.starts_with("---")),
454            "no removed lines: {diff}"
455        );
456    }
457
458    #[test]
459    fn flow_lint_on_runner_edits_only_the_value() {
460        let dir = scratch("flow-lint");
461        let path = write(
462            &dir,
463            "lint.yml",
464            "jobs: { lint: { runs-on: macos-latest, steps: [ { run: \"echo lint\" } ] } }\n",
465        );
466        let found = [finding(
467            &path,
468            1,
469            FindingKind::LintOnRunner,
470            "lint on macOS",
471        )];
472        let diff = render(&found, None).expect("diff");
473        assert!(
474            diff.contains(
475                "+jobs: { lint: { runs-on: ubuntu-latest, steps: [ { run: \"echo lint\" } ] } }\n"
476            ),
477            "{diff}"
478        );
479        assert!(
480            !diff.contains("-jobs: { lint: { runs-on: macos-latest,\n"),
481            "{diff}"
482        );
483    }
484
485    #[test]
486    fn flow_lint_on_runner_keeps_a_trailing_comma_and_comment() {
487        let dir = scratch("flow-lint-comma");
488        let path = write(
489            &dir,
490            "lint.yml",
491            "jobs: { lint: {\n    runs-on: macos-latest, # pick a runner\n    steps: [ { run: \"echo lint\" } ]\n  } }\n",
492        );
493        let found = [finding(
494            &path,
495            2,
496            FindingKind::LintOnRunner,
497            "lint on macOS",
498        )];
499        let diff = render(&found, None).expect("diff");
500        assert!(
501            diff.contains("+    runs-on: ubuntu-latest, # pick a runner\n"),
502            "{diff}"
503        );
504    }
505
506    #[test]
507    fn flow_missing_timeout_inserts_a_flow_entry_after_the_brace() {
508        let dir = scratch("flow-timeout");
509        let path = write(
510            &dir,
511            "lint.yml",
512            "jobs: { lint: { runs-on: ubuntu-latest, steps: [ { run: \"echo lint\" } ] } }\n",
513        );
514        let found = [finding(
515            &path,
516            1,
517            FindingKind::MissingTimeout,
518            "default timeout is 360 minutes",
519        )];
520        let diff = render(&found, None).expect("diff");
521        assert!(
522            diff.contains(
523                "+jobs: { lint: { timeout-minutes: 30, runs-on: ubuntu-latest, steps: [ { run: \"echo lint\" } ] } }\n"
524            ),
525            "{diff}"
526        );
527    }
528
529    #[test]
530    fn pull_request_concurrency_adds_cancel_in_progress_block() {
531        let dir = scratch("concurrency");
532        let path = write(&dir, "ci.yml", LINT_YML);
533        let found = [finding(
534            &path,
535            2,
536            FindingKind::PullRequestConcurrency,
537            "pull_request without concurrency cancel-in-progress",
538        )];
539        let diff = render(&found, None).expect("diff");
540        assert!(diff.contains("+concurrency:\n"), "{diff}");
541        assert!(
542            diff.contains("+  group: ${{ github.workflow }}-${{ github.ref }}\n"),
543            "{diff}"
544        );
545        assert!(diff.contains("+  cancel-in-progress: true\n"), "{diff}");
546    }
547
548    #[test]
549    fn skips_leading_findings_it_cannot_fix() {
550        let dir = scratch("order");
551        let path = write(&dir, "lint.yml", LINT_YML);
552        let found = [
553            finding(&path, 1, FindingKind::DuplicateKey, "duplicate key name"),
554            finding(&path, 8, FindingKind::LintOnRunner, "lint on macOS"),
555            finding(
556                &path,
557                2,
558                FindingKind::PullRequestConcurrency,
559                "pull_request without concurrency cancel-in-progress",
560            ),
561        ];
562        let diff = render(&found, None).expect("diff");
563        assert!(diff.contains("+    runs-on: ubuntu-latest\n"), "{diff}");
564    }
565
566    #[test]
567    fn no_fixable_finding_yields_no_diff() {
568        let dir = scratch("none");
569        let path = write(&dir, "lint.yml", LINT_YML);
570        let found = [finding(
571            &path,
572            1,
573            FindingKind::DuplicateKey,
574            "duplicate key name (line 1 and 1)",
575        )];
576        assert!(render(&found, None).is_none());
577    }
578
579    #[test]
580    fn crlf_file_keeps_crlf_in_added_lines() {
581        let dir = scratch("crlf");
582        let path = write(&dir, "lint.yml", &LINT_YML.replace('\n', "\r\n"));
583        let found = [finding(
584            &path,
585            8,
586            FindingKind::LintOnRunner,
587            "lint on macOS",
588        )];
589        let diff = render(&found, None).expect("diff");
590        assert!(diff.contains("-    runs-on: macos-latest\r\n"), "{diff}");
591        assert!(diff.contains("+    runs-on: ubuntu-latest\r\n"), "{diff}");
592    }
593
594    #[test]
595    fn composite_action_lint_finding_fixes_the_caller_runs_on() {
596        let dir = scratch("composite");
597        let workflows = dir.join(".github/workflows");
598        fs::create_dir_all(&workflows).expect("workflows dir");
599        let caller_path = workflows.join("lint.yml");
600        fs::write(&caller_path, LINT_YML).expect("caller");
601        let action_path = dir.join(".github/actions/lint/action.yml");
602        fs::create_dir_all(action_path.parent().unwrap()).expect("action dir");
603        fs::write(
604            &action_path,
605            "runs:\n  using: composite\n  steps:\n    - run: cargo fmt --check\n",
606        )
607        .expect("action");
608        let mut item = finding(&action_path, 4, FindingKind::LintOnRunner, "lint on macOS");
609        item.anchor = Some(8);
610        item.workflow_path = Some(".github/workflows/lint.yml".to_string());
611        let diff = render(&[item], Some(&workflows)).expect("diff");
612        assert!(
613            diff.starts_with(&format!("--- a/{}\n", diff_path(&caller_path))),
614            "{diff}"
615        );
616        assert!(diff.contains("+    runs-on: ubuntu-latest\n"), "{diff}");
617    }
618}