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: {}", 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        // 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                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    /// Builds a flag suffix string (e.g. " --workspace --features foo").
494    ///
495    /// `include_features` controls whether `--features <list>` is
496    /// emitted (suppressed when the caller already passes
497    /// `--no-default-features` or `--all-features` so cargo doesn't
498    /// fight the user). `force_workspace` lets a caller force the
499    /// workspace flag even when the generator's default is off.
500    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
529/// Emit a YAML plain scalar if the input is unambiguous, otherwise
530/// single-quote it (with `'` doubled per the YAML spec).
531///
532/// The allowed plain-scalar charset is alphanumerics plus
533/// `- _ . / * + = ~` (covers common branch patterns like
534/// `release/*`, version tags, and workflow names). Anything else, or
535/// any string that starts with a YAML indicator (`* & ? ! | > # @
536/// \` `, `-`, `?`, `:`, `,`, `[`, `]`, `{`, `}`, `%`), gets
537/// single-quoted to keep the output well-formed regardless of user
538/// input.
539fn 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
567/// Single-quote a value for inclusion in a POSIX `sh` / `bash` command.
568///
569/// Single-quoting in POSIX shell is literal — nothing inside is
570/// interpreted, so the only escape needed is for embedded `'`, which
571/// we replace with `'\''` (close-quote, escaped quote, re-open).
572fn 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); // once for push, once for pull_request
648    }
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        // Single quote escape sequence: '\''  (close-quote, backslash, quote, re-open).
715        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        // The default test job still gets --features foo
756        assert!(yaml.contains("cargo build --features foo --verbose"));
757        // The --no-default-features step doesn't double up with --features
758        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        // Sanity: confirm a "everything enabled" configuration produces
796        // valid-looking YAML with each section.
797        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}