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: {}", 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 writeln!(
453 out,
454 " git clone --depth 1 {} ../{}",
455 dep.repo_url, dep.name
456 )
457 .unwrap();
458 }
459 }
460 let toolchain = toolchain_pin.unwrap_or("stable");
461 writeln!(out, " - uses: dtolnay/rust-toolchain@{toolchain}").unwrap();
462 if let Some(c) = component {
463 out.push_str(" with:\n");
464 writeln!(out, " components: {c}").unwrap();
465 }
466 if self.rust_cache {
467 out.push_str(" - uses: Swatinem/rust-cache@v2\n");
468 }
469 }
470
471 fn write_cargo_step(
472 &self,
473 out: &mut String,
474 name: &str,
475 cmd: &str,
476 no_default_features: bool,
477 all_features: bool,
478 ) {
479 let flags = self.cargo_flags_string(!no_default_features && !all_features, false);
480 let extra = if no_default_features {
481 " --no-default-features".to_string()
482 } else if all_features {
483 " --all-features".to_string()
484 } else {
485 String::new()
486 };
487 writeln!(out, " - name: {name}").unwrap();
488 writeln!(out, " run: cargo {cmd}{flags}{extra} --verbose").unwrap();
489 }
490
491 fn cargo_flags_string(&self, include_features: bool, force_workspace: bool) -> String {
499 let mut s = String::new();
500 if self.workspace || force_workspace {
501 s.push_str(" --workspace");
502 }
503 if include_features {
504 if let Some(f) = &self.features {
505 if !f.is_empty() {
506 s.push_str(" --features ");
507 s.push_str(f);
508 }
509 }
510 }
511 s
512 }
513}
514
515fn write_branch_list(out: &mut String, indent: &str, branches: &[String]) {
516 out.push_str(indent);
517 out.push_str("branches: [");
518 for (i, b) in branches.iter().enumerate() {
519 if i > 0 {
520 out.push_str(", ");
521 }
522 out.push_str(b);
523 }
524 out.push_str("]\n");
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530
531 #[test]
532 fn default_generates_a_test_job() {
533 let yaml = Generator::new().generate();
534 assert!(yaml.contains("jobs:"));
535 assert!(yaml.contains("test:"));
536 assert!(yaml.contains("actions/checkout@v5"));
537 }
538
539 #[test]
540 fn clippy_job_added_when_requested() {
541 let yaml = Generator::new().with_clippy().generate();
542 assert!(yaml.contains("clippy:"));
543 assert!(yaml.contains("cargo clippy --all-targets --all-features"));
544 assert!(yaml.contains("-D warnings"));
545 }
546
547 #[test]
548 fn fmt_job_added_when_requested() {
549 let yaml = Generator::new().with_fmt().generate();
550 assert!(yaml.contains("fmt:"));
551 assert!(yaml.contains("cargo fmt --all -- --check"));
552 }
553
554 #[test]
555 fn docs_job_added_when_requested() {
556 let yaml = Generator::new().with_docs().generate();
557 assert!(yaml.contains("docs:"));
558 assert!(yaml.contains("RUSTDOCFLAGS"));
559 assert!(yaml.contains("cargo doc --all-features --no-deps"));
560 }
561
562 #[test]
563 fn msrv_job_uses_pinned_toolchain() {
564 let yaml = Generator::new().with_msrv("1.85").generate();
565 assert!(yaml.contains("rust-toolchain@1.85"));
566 assert!(yaml.contains("MSRV (Rust 1.85)"));
567 }
568
569 #[test]
570 fn matrix_os_appears_in_test_job() {
571 let yaml = Generator::new()
572 .matrix_os(["ubuntu-latest", "macos-latest", "windows-latest"])
573 .generate();
574 assert!(yaml.contains("[ubuntu-latest, macos-latest, windows-latest]"));
575 assert!(yaml.contains("runs-on: ${{ matrix.os }}"));
576 }
577
578 #[test]
579 fn empty_matrix_falls_back_to_default() {
580 let yaml: String = Generator::new().matrix_os(Vec::<&str>::new()).generate();
581 assert!(yaml.contains("[ubuntu-latest]"));
582 }
583
584 #[test]
585 fn branches_drive_both_push_and_pr_filters() {
586 let yaml = Generator::new().branches(["main", "release/*"]).generate();
587 let count = yaml.matches("branches: [main, release/*]").count();
588 assert_eq!(count, 2); }
590
591 #[test]
592 fn cache_action_present_by_default() {
593 let yaml = Generator::new().generate();
594 assert!(yaml.contains("Swatinem/rust-cache@v2"));
595 }
596
597 #[test]
598 fn cache_action_removed_when_disabled() {
599 let yaml = Generator::new().with_cache(false).generate();
600 assert!(!yaml.contains("Swatinem/rust-cache"));
601 }
602
603 #[test]
604 fn path_dep_clone_step_emitted() {
605 let yaml = Generator::new()
606 .with_path_dep(PathDep::new(
607 "dev-report",
608 "https://github.com/jamesgober/dev-report.git",
609 ))
610 .with_path_dep(PathDep::new(
611 "dev-tools",
612 "https://github.com/jamesgober/dev-tools.git",
613 ))
614 .generate();
615 assert!(yaml.contains("Check out sibling crates (path deps)"));
616 assert!(yaml.contains(
617 "git clone --depth 1 https://github.com/jamesgober/dev-report.git ../dev-report"
618 ));
619 assert!(yaml.contains(
620 "git clone --depth 1 https://github.com/jamesgober/dev-tools.git ../dev-tools"
621 ));
622 }
623
624 #[test]
625 fn no_default_features_build_emitted_when_requested() {
626 let yaml = Generator::new().with_no_default_features_build().generate();
627 assert!(yaml.contains("Build (no default features)"));
628 assert!(yaml.contains("cargo build --no-default-features --verbose"));
629 }
630
631 #[test]
632 fn all_features_build_and_test_emitted_when_requested() {
633 let yaml = Generator::new().with_all_features_build().generate();
634 assert!(yaml.contains("Build (all features)"));
635 assert!(yaml.contains("Test (all features)"));
636 assert!(yaml.contains("cargo build --all-features --verbose"));
637 assert!(yaml.contains("cargo test --all-features --verbose"));
638 }
639
640 #[test]
641 fn workspace_flag_propagates_to_cargo_calls() {
642 let yaml = Generator::new().with_workspace().generate();
643 assert!(yaml.contains("cargo build --workspace --verbose"));
644 assert!(yaml.contains("cargo test --workspace --verbose"));
645 }
646
647 #[test]
648 fn features_flag_propagates_when_set() {
649 let yaml = Generator::new().features("foo,bar").generate();
650 assert!(yaml.contains("cargo build --features foo,bar --verbose"));
651 assert!(yaml.contains("cargo test --features foo,bar --verbose"));
652 }
653
654 #[test]
655 fn features_flag_omitted_for_explicit_all_or_none() {
656 let yaml = Generator::new()
657 .features("foo")
658 .with_all_features_build()
659 .with_no_default_features_build()
660 .generate();
661 assert!(yaml.contains("cargo build --features foo --verbose"));
663 assert!(yaml.contains("cargo build --no-default-features --verbose"));
665 assert!(!yaml.contains("cargo build --no-default-features --features"));
666 }
667
668 #[test]
669 fn workflow_name_appears_at_top() {
670 let yaml = Generator::new().workflow_name("Pipeline").generate();
671 assert!(yaml.starts_with("name: Pipeline\n"));
672 }
673
674 #[test]
675 fn output_is_deterministic() {
676 let g = Generator::new()
677 .matrix_os(["ubuntu-latest", "macos-latest"])
678 .with_clippy()
679 .with_fmt()
680 .with_docs()
681 .with_msrv("1.85")
682 .with_no_default_features_build()
683 .with_all_features_build()
684 .with_path_dep(PathDep::new(
685 "dev-report",
686 "https://example.com/dev-report.git",
687 ));
688 let a = g.generate();
689 let b = g.generate();
690 assert_eq!(a, b);
691 }
692
693 #[test]
694 fn msrv_job_uses_pinned_toolchain_action_ref() {
695 let yaml = Generator::new().with_msrv("1.85").generate();
696 assert!(yaml.contains("dtolnay/rust-toolchain@1.85"));
697 }
698
699 #[test]
700 fn full_kitchen_sink_yaml_round_trip() {
701 let yaml = Generator::new()
704 .workflow_name("Full CI")
705 .branches(["main", "develop"])
706 .matrix_os(["ubuntu-latest", "macos-latest", "windows-latest"])
707 .with_clippy()
708 .with_fmt()
709 .with_docs()
710 .with_msrv("1.85")
711 .with_no_default_features_build()
712 .with_all_features_build()
713 .with_workspace()
714 .with_path_dep(PathDep::new(
715 "dev-report",
716 "https://example.com/dev-report.git",
717 ))
718 .generate();
719
720 for needle in [
721 "name: Full CI",
722 "actions/checkout@v5",
723 "branches: [main, develop]",
724 "[ubuntu-latest, macos-latest, windows-latest]",
725 "clippy:",
726 "fmt:",
727 "docs:",
728 "msrv:",
729 "MSRV (Rust 1.85)",
730 "Build (no default features)",
731 "Build (all features)",
732 "Test (all features)",
733 "git clone --depth 1 https://example.com/dev-report.git ../dev-report",
734 "Swatinem/rust-cache@v2",
735 ] {
736 assert!(
737 yaml.contains(needle),
738 "missing: {needle}\n--- yaml ---\n{yaml}"
739 );
740 }
741 }
742
743 #[test]
744 fn path_dep_accessors_round_trip() {
745 let d = PathDep::new("foo", "https://example.com/foo.git");
746 assert_eq!(d.name(), "foo");
747 assert_eq!(d.repo_url(), "https://example.com/foo.git");
748 }
749
750 #[test]
751 fn default_target_is_github_actions() {
752 assert_eq!(Generator::new().target_kind(), Target::GitHubActions);
753 }
754}