Skip to main content

dev_ci/
lib.rs

1//! # dev-ci
2//!
3//! CI workflow generator for the `dev-*` verification suite.
4//!
5//! `dev-ci` does two things:
6//!
7//! 1. **Generate** calibrated CI pipelines (`.github/workflows/ci.yml`,
8//!    others to follow) tailored to the dev-* features a project uses.
9//! 2. **Run** the suite end-to-end on pull requests (planned for later
10//!    in the 0.9.x line — the runtime side ships as a GitHub Action).
11//!
12//! ## Quick example
13//!
14//! ```
15//! use dev_ci::{Generator, Target};
16//!
17//! let yaml = Generator::new()
18//!     .target(Target::GitHubActions)
19//!     .with_clippy()
20//!     .with_fmt()
21//!     .with_docs()
22//!     .with_msrv("1.85")
23//!     .generate();
24//!
25//! assert!(yaml.contains("actions/checkout@v5"));
26//! ```
27//!
28//! ## Determinism
29//!
30//! Output is byte-deterministic for a given [`Generator`] configuration.
31//! No clock reads, no random IDs, lists iterate in insertion order.
32//!
33//! ## What's in 0.9.0
34//!
35//! - GitHub Actions output (every job uses `actions/checkout@v5`,
36//!   `Swatinem/rust-cache@v2`, and the documented patterns from the
37//!   existing dev-* suite CI).
38//! - Builder methods for the full standard surface: `test` matrix,
39//!   feature toggles, `clippy`, `fmt`, `docs`, `msrv`, sibling
40//!   path-dep cloning, cache toggle, custom workflow name + branches.
41//! - CLI binary (`dev-ci generate ...`) wrapping the library.
42//!
43//! Other targets (GitLab, Buildkite, CircleCI) and the runtime-action
44//! side are planned for later 0.9.x releases.
45
46#![cfg_attr(docsrs, feature(doc_cfg))]
47#![warn(missing_docs)]
48#![warn(rust_2018_idioms)]
49
50use std::fmt::Write as _;
51
52// ---------------------------------------------------------------------------
53// Target
54// ---------------------------------------------------------------------------
55
56/// Supported CI target platforms.
57///
58/// Only [`Target::GitHubActions`] is implemented in 0.9.0; other
59/// targets (GitLab CI, Buildkite, CircleCI) land in subsequent 0.9.x
60/// releases.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum Target {
63    /// GitHub Actions workflow YAML.
64    GitHubActions,
65}
66
67// ---------------------------------------------------------------------------
68// PathDep
69// ---------------------------------------------------------------------------
70
71/// A sibling path-dependency that the CI workflow should `git clone`
72/// before running cargo.
73///
74/// This matches the pattern the existing dev-* suite uses: each
75/// crate's CI clones its siblings into `../<name>` so path-deps
76/// resolve cleanly under a sibling-only checkout.
77///
78/// # Example
79///
80/// ```
81/// use dev_ci::PathDep;
82///
83/// let dep = PathDep::new("dev-report", "https://github.com/jamesgober/dev-report.git");
84/// assert_eq!(dep.name(), "dev-report");
85/// ```
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct PathDep {
88    name: String,
89    repo_url: String,
90}
91
92impl PathDep {
93    /// Build a path-dep descriptor.
94    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    /// Sibling directory name (cloned under `../<name>`).
102    pub fn name(&self) -> &str {
103        &self.name
104    }
105
106    /// HTTPS / SSH clone URL.
107    pub fn repo_url(&self) -> &str {
108        &self.repo_url
109    }
110}
111
112// ---------------------------------------------------------------------------
113// Generator
114// ---------------------------------------------------------------------------
115
116/// Builder for a CI workflow document.
117///
118/// Methods are pure setters; configuration is committed only when
119/// [`generate`](Self::generate) is called. Calling `generate` multiple
120/// times against the same `Generator` returns byte-identical output.
121///
122/// # Example
123///
124/// ```
125/// use dev_ci::{Generator, PathDep, Target};
126///
127/// let yaml = Generator::new()
128///     .target(Target::GitHubActions)
129///     .workflow_name("CI")
130///     .branches(["main", "release/*"])
131///     .matrix_os(["ubuntu-latest", "macos-latest", "windows-latest"])
132///     .with_clippy()
133///     .with_fmt()
134///     .with_docs()
135///     .with_msrv("1.85")
136///     .with_no_default_features_build()
137///     .with_all_features_build()
138///     .with_path_dep(PathDep::new("dev-report", "https://github.com/jamesgober/dev-report.git"))
139///     .generate();
140///
141/// assert!(yaml.contains("name: CI"));
142/// assert!(yaml.contains("actions/checkout@v5"));
143/// ```
144#[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    /// Begin a new generator with default settings.
170    ///
171    /// Defaults: target = `GitHubActions`, workflow name = `"CI"`,
172    /// branches = `["main"]`, matrix = `["ubuntu-latest"]`, cache
173    /// enabled, no extra jobs.
174    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    /// Select the target CI platform.
194    pub fn target(mut self, target: Target) -> Self {
195        self.target = target;
196        self
197    }
198
199    /// Selected target.
200    pub fn target_kind(&self) -> Target {
201        self.target
202    }
203
204    /// Set the workflow `name:` field. Defaults to `"CI"`.
205    pub fn workflow_name(mut self, name: impl Into<String>) -> Self {
206        self.workflow_name = name.into();
207        self
208    }
209
210    /// Set the `push` / `pull_request` branch filter list.
211    ///
212    /// Defaults to `["main"]`. Glob patterns are passed through unchanged.
213    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    /// Set the OS matrix for the `test` job. Defaults to a single
226    /// `ubuntu-latest` runner.
227    ///
228    /// Common multi-OS configuration:
229    /// `["ubuntu-latest", "macos-latest", "windows-latest"]`.
230    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    /// Toggle the `Swatinem/rust-cache@v2` action. Default: enabled.
243    pub fn with_cache(mut self, enabled: bool) -> Self {
244        self.rust_cache = enabled;
245        self
246    }
247
248    /// Pass `--workspace` to every cargo invocation.
249    pub fn with_workspace(mut self) -> Self {
250        self.workspace = true;
251        self
252    }
253
254    /// Pass `--features <list>` to every cargo invocation.
255    ///
256    /// Mutually exclusive with [`with_all_features_build`](Self::with_all_features_build)
257    /// only in the sense that `--all-features` overrides `--features`
258    /// in cargo itself; the generator emits both flags as configured.
259    pub fn features(mut self, features: impl Into<String>) -> Self {
260        self.features = Some(features.into());
261        self
262    }
263
264    /// Emit an additional build step under the `test` job that passes
265    /// `--no-default-features`. Useful for crates with optional
266    /// features that should compile cleanly under the bare configuration.
267    pub fn with_no_default_features_build(mut self) -> Self {
268        self.no_default_features_build = true;
269        self
270    }
271
272    /// Emit an additional build + test step under the `test` job that
273    /// passes `--all-features`.
274    pub fn with_all_features_build(mut self) -> Self {
275        self.all_features_build = true;
276        self
277    }
278
279    /// Declare a sibling path-dependency that the workflow must
280    /// `git clone` into `../<name>` before running cargo.
281    ///
282    /// Matches the pattern the existing dev-* suite uses for its own
283    /// CI (each crate clones its siblings into `..`). May be called
284    /// repeatedly.
285    pub fn with_path_dep(mut self, dep: PathDep) -> Self {
286        self.path_deps.push(dep);
287        self
288    }
289
290    /// Include a clippy job.
291    pub fn with_clippy(mut self) -> Self {
292        self.clippy = true;
293        self
294    }
295
296    /// Include a rustfmt-check job.
297    pub fn with_fmt(mut self) -> Self {
298        self.fmt = true;
299        self
300    }
301
302    /// Include a `cargo doc` job with `RUSTDOCFLAGS="-D warnings"`.
303    pub fn with_docs(mut self) -> Self {
304        self.docs = true;
305        self
306    }
307
308    /// Include an MSRV job pinned to the given Rust version.
309    pub fn with_msrv(mut self, version: impl Into<String>) -> Self {
310        self.msrv = Some(version.into());
311        self
312    }
313
314    /// Render the workflow document.
315    ///
316    /// Output is byte-deterministic for a given configuration.
317    pub fn generate(&self) -> String {
318        match self.target {
319            Target::GitHubActions => self.render_github_actions(),
320        }
321    }
322
323    // -----------------------------------------------------------------------
324    // GitHub Actions renderer
325    // -----------------------------------------------------------------------
326
327    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        // Default build + test.
378        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    /// Builds a flag suffix string (e.g. " --workspace --features foo").
492    ///
493    /// `include_features` controls whether `--features <list>` is
494    /// emitted (suppressed when the caller already passes
495    /// `--no-default-features` or `--all-features` so cargo doesn't
496    /// fight the user). `force_workspace` lets a caller force the
497    /// workspace flag even when the generator's default is off.
498    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); // once for push, once for pull_request
589    }
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        // The default test job still gets --features foo
662        assert!(yaml.contains("cargo build --features foo --verbose"));
663        // The --no-default-features step doesn't double up with --features
664        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        // Sanity: confirm a "everything enabled" configuration produces
702        // valid-looking YAML with each section.
703        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}