1#![cfg_attr(docsrs, feature(doc_cfg))]
47#![warn(missing_docs)]
48#![warn(rust_2018_idioms)]
49
50use std::fmt::Write as _;
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum Target {
63 GitHubActions,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct PathDep {
88 name: String,
89 repo_url: String,
90}
91
92impl PathDep {
93 pub fn new(name: impl Into<String>, repo_url: impl Into<String>) -> Self {
95 Self {
96 name: name.into(),
97 repo_url: repo_url.into(),
98 }
99 }
100
101 pub fn name(&self) -> &str {
103 &self.name
104 }
105
106 pub fn repo_url(&self) -> &str {
108 &self.repo_url
109 }
110}
111
112#[derive(Debug, Clone)]
145pub struct Generator {
146 target: Target,
147 workflow_name: String,
148 branches: Vec<String>,
149 matrix_os: Vec<String>,
150 rust_cache: bool,
151 workspace: bool,
152 features: Option<String>,
153 no_default_features_build: bool,
154 all_features_build: bool,
155 path_deps: Vec<PathDep>,
156 clippy: bool,
157 fmt: bool,
158 docs: bool,
159 msrv: Option<String>,
160}
161
162impl Default for Generator {
163 fn default() -> Self {
164 Self::new()
165 }
166}
167
168impl Generator {
169 pub fn new() -> Self {
175 Self {
176 target: Target::GitHubActions,
177 workflow_name: "CI".into(),
178 branches: vec!["main".into()],
179 matrix_os: vec!["ubuntu-latest".into()],
180 rust_cache: true,
181 workspace: false,
182 features: None,
183 no_default_features_build: false,
184 all_features_build: false,
185 path_deps: Vec::new(),
186 clippy: false,
187 fmt: false,
188 docs: false,
189 msrv: None,
190 }
191 }
192
193 pub fn target(mut self, target: Target) -> Self {
195 self.target = target;
196 self
197 }
198
199 pub fn target_kind(&self) -> Target {
201 self.target
202 }
203
204 pub fn workflow_name(mut self, name: impl Into<String>) -> Self {
206 self.workflow_name = name.into();
207 self
208 }
209
210 pub fn branches<I, S>(mut self, branches: I) -> Self
214 where
215 I: IntoIterator<Item = S>,
216 S: Into<String>,
217 {
218 self.branches = branches.into_iter().map(Into::into).collect();
219 if self.branches.is_empty() {
220 self.branches.push("main".into());
221 }
222 self
223 }
224
225 pub fn matrix_os<I, S>(mut self, os_list: I) -> Self
231 where
232 I: IntoIterator<Item = S>,
233 S: Into<String>,
234 {
235 self.matrix_os = os_list.into_iter().map(Into::into).collect();
236 if self.matrix_os.is_empty() {
237 self.matrix_os.push("ubuntu-latest".into());
238 }
239 self
240 }
241
242 pub fn with_cache(mut self, enabled: bool) -> Self {
244 self.rust_cache = enabled;
245 self
246 }
247
248 pub fn with_workspace(mut self) -> Self {
250 self.workspace = true;
251 self
252 }
253
254 pub fn features(mut self, features: impl Into<String>) -> Self {
260 self.features = Some(features.into());
261 self
262 }
263
264 pub fn with_no_default_features_build(mut self) -> Self {
268 self.no_default_features_build = true;
269 self
270 }
271
272 pub fn with_all_features_build(mut self) -> Self {
275 self.all_features_build = true;
276 self
277 }
278
279 pub fn with_path_dep(mut self, dep: PathDep) -> Self {
286 self.path_deps.push(dep);
287 self
288 }
289
290 pub fn with_clippy(mut self) -> Self {
292 self.clippy = true;
293 self
294 }
295
296 pub fn with_fmt(mut self) -> Self {
298 self.fmt = true;
299 self
300 }
301
302 pub fn with_docs(mut self) -> Self {
304 self.docs = true;
305 self
306 }
307
308 pub fn with_msrv(mut self, version: impl Into<String>) -> Self {
310 self.msrv = Some(version.into());
311 self
312 }
313
314 pub fn generate(&self) -> String {
318 match self.target {
319 Target::GitHubActions => self.render_github_actions(),
320 }
321 }
322
323 fn render_github_actions(&self) -> String {
328 let mut out = String::with_capacity(2048);
329 self.write_header(&mut out);
330 out.push_str("jobs:\n");
331 self.write_test_job(&mut out);
332 if self.clippy {
333 self.write_clippy_job(&mut out);
334 }
335 if self.fmt {
336 self.write_fmt_job(&mut out);
337 }
338 if self.docs {
339 self.write_docs_job(&mut out);
340 }
341 if let Some(msrv) = self.msrv.clone() {
342 self.write_msrv_job(&mut out, &msrv);
343 }
344 out
345 }
346
347 fn write_header(&self, out: &mut String) {
348 writeln!(out, "name: {}", yaml_scalar(&self.workflow_name)).unwrap();
349 out.push('\n');
350 out.push_str("on:\n");
351 out.push_str(" push:\n");
352 write_branch_list(out, " ", &self.branches);
353 out.push_str(" pull_request:\n");
354 write_branch_list(out, " ", &self.branches);
355 out.push('\n');
356 out.push_str("env:\n CARGO_TERM_COLOR: always\n\n");
357 }
358
359 fn write_test_job(&self, out: &mut String) {
360 out.push_str(" test:\n");
361 out.push_str(" name: Test (${{ matrix.os }})\n");
362 out.push_str(" runs-on: ${{ matrix.os }}\n");
363 out.push_str(" strategy:\n");
364 out.push_str(" fail-fast: false\n");
365 out.push_str(" matrix:\n");
366 out.push_str(" os: [");
367 for (i, os) in self.matrix_os.iter().enumerate() {
368 if i > 0 {
369 out.push_str(", ");
370 }
371 out.push_str(os);
372 }
373 out.push_str("]\n");
374 out.push_str(" steps:\n");
375 self.write_common_setup(out);
376
377 self.write_cargo_step(out, "Build", "build", false, false);
379 self.write_cargo_step(out, "Test", "test", false, false);
380
381 if self.no_default_features_build {
382 self.write_cargo_step(out, "Build (no default features)", "build", true, false);
383 }
384 if self.all_features_build {
385 self.write_cargo_step(out, "Build (all features)", "build", false, true);
386 self.write_cargo_step(out, "Test (all features)", "test", false, true);
387 }
388 }
389
390 fn write_clippy_job(&self, out: &mut String) {
391 out.push_str("\n clippy:\n");
392 out.push_str(" name: Clippy\n");
393 out.push_str(" runs-on: ubuntu-latest\n");
394 out.push_str(" steps:\n");
395 self.write_common_setup_components(out, Some("clippy"), None);
396 out.push_str(" - name: Clippy (all features)\n");
397 out.push_str(" run: cargo clippy --all-targets --all-features -- -D warnings\n");
398 out.push_str(" - name: Clippy (no default features)\n");
399 out.push_str(
400 " run: cargo clippy --all-targets --no-default-features -- -D warnings\n",
401 );
402 }
403
404 fn write_fmt_job(&self, out: &mut String) {
405 out.push_str("\n fmt:\n");
406 out.push_str(" name: Rustfmt\n");
407 out.push_str(" runs-on: ubuntu-latest\n");
408 out.push_str(" steps:\n");
409 out.push_str(" - uses: actions/checkout@v5\n");
410 out.push_str(" - uses: dtolnay/rust-toolchain@stable\n");
411 out.push_str(" with:\n");
412 out.push_str(" components: rustfmt\n");
413 out.push_str(" - run: cargo fmt --all -- --check\n");
414 }
415
416 fn write_docs_job(&self, out: &mut String) {
417 out.push_str("\n docs:\n");
418 out.push_str(" name: Doc build\n");
419 out.push_str(" runs-on: ubuntu-latest\n");
420 out.push_str(" env:\n");
421 out.push_str(" RUSTDOCFLAGS: \"-D warnings\"\n");
422 out.push_str(" steps:\n");
423 self.write_common_setup(out);
424 out.push_str(" - run: cargo doc --all-features --no-deps\n");
425 }
426
427 fn write_msrv_job(&self, out: &mut String, msrv: &str) {
428 writeln!(out, "\n msrv:").unwrap();
429 writeln!(out, " name: MSRV (Rust {msrv})").unwrap();
430 out.push_str(" runs-on: ubuntu-latest\n");
431 out.push_str(" steps:\n");
432 self.write_common_setup_components(out, None, Some(msrv));
433 let extras = self.cargo_flags_string(true, false);
434 writeln!(out, " - run: cargo build{extras}").unwrap();
435 }
436
437 fn write_common_setup(&self, out: &mut String) {
438 self.write_common_setup_components(out, None, None);
439 }
440
441 fn write_common_setup_components(
442 &self,
443 out: &mut String,
444 component: Option<&str>,
445 toolchain_pin: Option<&str>,
446 ) {
447 out.push_str(" - uses: actions/checkout@v5\n");
448 if !self.path_deps.is_empty() {
449 out.push_str(" - name: Check out sibling crates (path deps)\n");
450 out.push_str(" run: |\n");
451 for dep in &self.path_deps {
452 let target = format!("../{}", dep.name);
453 writeln!(
454 out,
455 " git clone --depth 1 {} {}",
456 shell_arg(&dep.repo_url),
457 shell_arg(&target),
458 )
459 .unwrap();
460 }
461 }
462 let toolchain = toolchain_pin.unwrap_or("stable");
463 writeln!(out, " - uses: dtolnay/rust-toolchain@{toolchain}").unwrap();
464 if let Some(c) = component {
465 out.push_str(" with:\n");
466 writeln!(out, " components: {c}").unwrap();
467 }
468 if self.rust_cache {
469 out.push_str(" - uses: Swatinem/rust-cache@v2\n");
470 }
471 }
472
473 fn write_cargo_step(
474 &self,
475 out: &mut String,
476 name: &str,
477 cmd: &str,
478 no_default_features: bool,
479 all_features: bool,
480 ) {
481 let flags = self.cargo_flags_string(!no_default_features && !all_features, false);
482 let extra = if no_default_features {
483 " --no-default-features".to_string()
484 } else if all_features {
485 " --all-features".to_string()
486 } else {
487 String::new()
488 };
489 writeln!(out, " - name: {name}").unwrap();
490 writeln!(out, " run: cargo {cmd}{flags}{extra} --verbose").unwrap();
491 }
492
493 fn cargo_flags_string(&self, include_features: bool, force_workspace: bool) -> String {
501 let mut s = String::new();
502 if self.workspace || force_workspace {
503 s.push_str(" --workspace");
504 }
505 if include_features {
506 if let Some(f) = &self.features {
507 if !f.is_empty() {
508 s.push_str(" --features ");
509 s.push_str(f);
510 }
511 }
512 }
513 s
514 }
515}
516
517fn write_branch_list(out: &mut String, indent: &str, branches: &[String]) {
518 out.push_str(indent);
519 out.push_str("branches: [");
520 for (i, b) in branches.iter().enumerate() {
521 if i > 0 {
522 out.push_str(", ");
523 }
524 out.push_str(&yaml_scalar(b));
525 }
526 out.push_str("]\n");
527}
528
529fn yaml_scalar(s: &str) -> String {
540 let is_plain_charset = !s.is_empty()
541 && s.chars().all(|c| {
542 c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | '*' | '+' | '=' | '~')
543 });
544 let starts_with_indicator = matches!(
545 s.chars().next(),
546 Some('-') | Some('?') | Some(':') | Some(',') | Some('[') | Some(']') | Some('{')
547 | Some('}') | Some('#') | Some('&') | Some('*') | Some('!') | Some('|') | Some('>')
548 | Some('%') | Some('@') | Some('`')
549 );
550 if is_plain_charset && !starts_with_indicator {
551 s.to_string()
552 } else {
553 let mut out = String::with_capacity(s.len() + 2);
554 out.push('\'');
555 for ch in s.chars() {
556 if ch == '\'' {
557 out.push_str("''");
558 } else {
559 out.push(ch);
560 }
561 }
562 out.push('\'');
563 out
564 }
565}
566
567fn shell_arg(s: &str) -> String {
573 let mut out = String::with_capacity(s.len() + 2);
574 out.push('\'');
575 for ch in s.chars() {
576 if ch == '\'' {
577 out.push_str("'\\''");
578 } else {
579 out.push(ch);
580 }
581 }
582 out.push('\'');
583 out
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589
590 #[test]
591 fn default_generates_a_test_job() {
592 let yaml = Generator::new().generate();
593 assert!(yaml.contains("jobs:"));
594 assert!(yaml.contains("test:"));
595 assert!(yaml.contains("actions/checkout@v5"));
596 }
597
598 #[test]
599 fn clippy_job_added_when_requested() {
600 let yaml = Generator::new().with_clippy().generate();
601 assert!(yaml.contains("clippy:"));
602 assert!(yaml.contains("cargo clippy --all-targets --all-features"));
603 assert!(yaml.contains("-D warnings"));
604 }
605
606 #[test]
607 fn fmt_job_added_when_requested() {
608 let yaml = Generator::new().with_fmt().generate();
609 assert!(yaml.contains("fmt:"));
610 assert!(yaml.contains("cargo fmt --all -- --check"));
611 }
612
613 #[test]
614 fn docs_job_added_when_requested() {
615 let yaml = Generator::new().with_docs().generate();
616 assert!(yaml.contains("docs:"));
617 assert!(yaml.contains("RUSTDOCFLAGS"));
618 assert!(yaml.contains("cargo doc --all-features --no-deps"));
619 }
620
621 #[test]
622 fn msrv_job_uses_pinned_toolchain() {
623 let yaml = Generator::new().with_msrv("1.85").generate();
624 assert!(yaml.contains("rust-toolchain@1.85"));
625 assert!(yaml.contains("MSRV (Rust 1.85)"));
626 }
627
628 #[test]
629 fn matrix_os_appears_in_test_job() {
630 let yaml = Generator::new()
631 .matrix_os(["ubuntu-latest", "macos-latest", "windows-latest"])
632 .generate();
633 assert!(yaml.contains("[ubuntu-latest, macos-latest, windows-latest]"));
634 assert!(yaml.contains("runs-on: ${{ matrix.os }}"));
635 }
636
637 #[test]
638 fn empty_matrix_falls_back_to_default() {
639 let yaml: String = Generator::new().matrix_os(Vec::<&str>::new()).generate();
640 assert!(yaml.contains("[ubuntu-latest]"));
641 }
642
643 #[test]
644 fn branches_drive_both_push_and_pr_filters() {
645 let yaml = Generator::new().branches(["main", "release/*"]).generate();
646 let count = yaml.matches("branches: [main, release/*]").count();
647 assert_eq!(count, 2); }
649
650 #[test]
651 fn cache_action_present_by_default() {
652 let yaml = Generator::new().generate();
653 assert!(yaml.contains("Swatinem/rust-cache@v2"));
654 }
655
656 #[test]
657 fn cache_action_removed_when_disabled() {
658 let yaml = Generator::new().with_cache(false).generate();
659 assert!(!yaml.contains("Swatinem/rust-cache"));
660 }
661
662 #[test]
663 fn path_dep_clone_step_emitted() {
664 let yaml = Generator::new()
665 .with_path_dep(PathDep::new(
666 "dev-report",
667 "https://github.com/jamesgober/dev-report.git",
668 ))
669 .with_path_dep(PathDep::new(
670 "dev-tools",
671 "https://github.com/jamesgober/dev-tools.git",
672 ))
673 .generate();
674 assert!(yaml.contains("Check out sibling crates (path deps)"));
675 assert!(yaml.contains(
676 "git clone --depth 1 'https://github.com/jamesgober/dev-report.git' '../dev-report'"
677 ));
678 assert!(yaml.contains(
679 "git clone --depth 1 'https://github.com/jamesgober/dev-tools.git' '../dev-tools'"
680 ));
681 }
682
683 #[test]
684 fn yaml_scalar_quotes_workflow_name_with_colon() {
685 let yaml = Generator::new()
686 .workflow_name("Build: CI Pipeline")
687 .generate();
688 assert!(yaml.contains("name: 'Build: CI Pipeline'"));
689 }
690
691 #[test]
692 fn yaml_scalar_doubles_embedded_single_quote() {
693 let yaml = Generator::new().workflow_name("Don't break").generate();
694 assert!(yaml.contains("name: 'Don''t break'"));
695 }
696
697 #[test]
698 fn yaml_scalar_quotes_branch_with_comma() {
699 let yaml = Generator::new().branches(["main", "release,foo"]).generate();
700 assert!(yaml.contains("branches: [main, 'release,foo']"));
701 }
702
703 #[test]
704 fn yaml_scalar_quotes_branch_starting_with_indicator() {
705 let yaml = Generator::new().branches(["main", "*release"]).generate();
706 assert!(yaml.contains("branches: [main, '*release']"));
707 }
708
709 #[test]
710 fn shell_arg_escapes_embedded_single_quote() {
711 let yaml = Generator::new()
712 .with_path_dep(PathDep::new("weird-name", "https://x.com/o'malley.git"))
713 .generate();
714 assert!(yaml.contains("'https://x.com/o'\\''malley.git' '../weird-name'"));
716 }
717
718 #[test]
719 fn no_default_features_build_emitted_when_requested() {
720 let yaml = Generator::new().with_no_default_features_build().generate();
721 assert!(yaml.contains("Build (no default features)"));
722 assert!(yaml.contains("cargo build --no-default-features --verbose"));
723 }
724
725 #[test]
726 fn all_features_build_and_test_emitted_when_requested() {
727 let yaml = Generator::new().with_all_features_build().generate();
728 assert!(yaml.contains("Build (all features)"));
729 assert!(yaml.contains("Test (all features)"));
730 assert!(yaml.contains("cargo build --all-features --verbose"));
731 assert!(yaml.contains("cargo test --all-features --verbose"));
732 }
733
734 #[test]
735 fn workspace_flag_propagates_to_cargo_calls() {
736 let yaml = Generator::new().with_workspace().generate();
737 assert!(yaml.contains("cargo build --workspace --verbose"));
738 assert!(yaml.contains("cargo test --workspace --verbose"));
739 }
740
741 #[test]
742 fn features_flag_propagates_when_set() {
743 let yaml = Generator::new().features("foo,bar").generate();
744 assert!(yaml.contains("cargo build --features foo,bar --verbose"));
745 assert!(yaml.contains("cargo test --features foo,bar --verbose"));
746 }
747
748 #[test]
749 fn features_flag_omitted_for_explicit_all_or_none() {
750 let yaml = Generator::new()
751 .features("foo")
752 .with_all_features_build()
753 .with_no_default_features_build()
754 .generate();
755 assert!(yaml.contains("cargo build --features foo --verbose"));
757 assert!(yaml.contains("cargo build --no-default-features --verbose"));
759 assert!(!yaml.contains("cargo build --no-default-features --features"));
760 }
761
762 #[test]
763 fn workflow_name_appears_at_top() {
764 let yaml = Generator::new().workflow_name("Pipeline").generate();
765 assert!(yaml.starts_with("name: Pipeline\n"));
766 }
767
768 #[test]
769 fn output_is_deterministic() {
770 let g = Generator::new()
771 .matrix_os(["ubuntu-latest", "macos-latest"])
772 .with_clippy()
773 .with_fmt()
774 .with_docs()
775 .with_msrv("1.85")
776 .with_no_default_features_build()
777 .with_all_features_build()
778 .with_path_dep(PathDep::new(
779 "dev-report",
780 "https://example.com/dev-report.git",
781 ));
782 let a = g.generate();
783 let b = g.generate();
784 assert_eq!(a, b);
785 }
786
787 #[test]
788 fn msrv_job_uses_pinned_toolchain_action_ref() {
789 let yaml = Generator::new().with_msrv("1.85").generate();
790 assert!(yaml.contains("dtolnay/rust-toolchain@1.85"));
791 }
792
793 #[test]
794 fn full_kitchen_sink_yaml_round_trip() {
795 let yaml = Generator::new()
798 .workflow_name("Full CI")
799 .branches(["main", "develop"])
800 .matrix_os(["ubuntu-latest", "macos-latest", "windows-latest"])
801 .with_clippy()
802 .with_fmt()
803 .with_docs()
804 .with_msrv("1.85")
805 .with_no_default_features_build()
806 .with_all_features_build()
807 .with_workspace()
808 .with_path_dep(PathDep::new(
809 "dev-report",
810 "https://example.com/dev-report.git",
811 ))
812 .generate();
813
814 for needle in [
815 "name: 'Full CI'",
816 "actions/checkout@v5",
817 "branches: [main, develop]",
818 "[ubuntu-latest, macos-latest, windows-latest]",
819 "clippy:",
820 "fmt:",
821 "docs:",
822 "msrv:",
823 "MSRV (Rust 1.85)",
824 "Build (no default features)",
825 "Build (all features)",
826 "Test (all features)",
827 "git clone --depth 1 'https://example.com/dev-report.git' '../dev-report'",
828 "Swatinem/rust-cache@v2",
829 ] {
830 assert!(
831 yaml.contains(needle),
832 "missing: {needle}\n--- yaml ---\n{yaml}"
833 );
834 }
835 }
836
837 #[test]
838 fn path_dep_accessors_round_trip() {
839 let d = PathDep::new("foo", "https://example.com/foo.git");
840 assert_eq!(d.name(), "foo");
841 assert_eq!(d.repo_url(), "https://example.com/foo.git");
842 }
843
844 #[test]
845 fn default_target_is_github_actions() {
846 assert_eq!(Generator::new().target_kind(), Target::GitHubActions);
847 }
848}