1use 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
17pub 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
36fn 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 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 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
147fn 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
162fn 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
186fn 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("e) {
203 idx += 2;
204 continue;
205 }
206 return Some(idx + 1);
207 }
208 idx += 1;
209 }
210 None
211}
212
213fn 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
232fn 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
278fn 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
304fn 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
322fn 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}