Skip to main content

dev_ci/
lib.rs

1//! # dev-ci
2//!
3//! CI workflow generator and GitHub Action for the `dev-*` verification suite.
4//!
5//! `dev-ci` does two things:
6//!
7//! 1. **Generate** calibrated CI pipelines (`.github/workflows/ci.yml`,
8//!    `.gitlab-ci.yml`, etc.) tailored to the dev-* features a project uses.
9//! 2. **Run** the suite end-to-end on pull requests, posting structured
10//!    annotations and uploading SARIF for the GitHub Security tab.
11//!
12//! ## Quick example
13//!
14//! ```no_run
15//! use dev_ci::{Generator, Target};
16//!
17//! let yaml = Generator::new()
18//!     .target(Target::GitHubActions)
19//!     .with_clippy()
20//!     .with_fmt()
21//!     .with_msrv("1.85")
22//!     .generate();
23//!
24//! std::fs::write(".github/workflows/ci.yml", yaml).unwrap();
25//! ```
26//!
27//! ## Status
28//!
29//! Pre-1.0. APIs may shift through the `0.9.x` line. See the
30//! [`CHANGELOG`](https://github.com/jamesgober/dev-ci/blob/main/CHANGELOG.md)
31//! for what's stable.
32
33#![cfg_attr(docsrs, feature(doc_cfg))]
34#![warn(missing_docs)]
35#![warn(rust_2018_idioms)]
36
37/// Supported CI target platforms.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Target {
40    /// GitHub Actions workflow YAML.
41    GitHubActions,
42}
43
44/// Builder for a CI workflow document.
45///
46/// # Example
47///
48/// ```
49/// use dev_ci::{Generator, Target};
50///
51/// let g = Generator::new().target(Target::GitHubActions);
52/// assert_eq!(g.target_kind(), Target::GitHubActions);
53/// ```
54#[derive(Debug, Clone)]
55pub struct Generator {
56    target: Target,
57    clippy: bool,
58    fmt: bool,
59    msrv: Option<String>,
60}
61
62impl Default for Generator {
63    fn default() -> Self {
64        Self::new()
65    }
66}
67
68impl Generator {
69    /// Begin a new generator with default settings.
70    pub fn new() -> Self {
71        Self {
72            target: Target::GitHubActions,
73            clippy: false,
74            fmt: false,
75            msrv: None,
76        }
77    }
78
79    /// Select the target CI platform.
80    pub fn target(mut self, target: Target) -> Self {
81        self.target = target;
82        self
83    }
84
85    /// Include a Clippy job.
86    pub fn with_clippy(mut self) -> Self {
87        self.clippy = true;
88        self
89    }
90
91    /// Include a rustfmt check job.
92    pub fn with_fmt(mut self) -> Self {
93        self.fmt = true;
94        self
95    }
96
97    /// Include an MSRV verification job pinned to the given Rust version.
98    pub fn with_msrv(mut self, version: impl Into<String>) -> Self {
99        self.msrv = Some(version.into());
100        self
101    }
102
103    /// Selected target.
104    pub fn target_kind(&self) -> Target {
105        self.target
106    }
107
108    /// Render the workflow document.
109    pub fn generate(&self) -> String {
110        // 0.9.0 stub. Full implementation lands in 0.9.1+.
111        match self.target {
112            Target::GitHubActions => self.render_github_actions(),
113        }
114    }
115
116    fn render_github_actions(&self) -> String {
117        let mut out = String::new();
118        out.push_str("name: CI\n\n");
119        out.push_str(
120            "on:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\n",
121        );
122        out.push_str("env:\n  CARGO_TERM_COLOR: always\n\n");
123        out.push_str("jobs:\n");
124        out.push_str("  test:\n    runs-on: ubuntu-latest\n    steps:\n");
125        out.push_str("      - uses: actions/checkout@v5\n");
126        out.push_str("      - uses: dtolnay/rust-toolchain@stable\n");
127        out.push_str("      - run: cargo test --all-features\n");
128        if self.clippy {
129            out.push_str("  clippy:\n    runs-on: ubuntu-latest\n    steps:\n");
130            out.push_str("      - uses: actions/checkout@v5\n");
131            out.push_str(
132                "      - uses: dtolnay/rust-toolchain@stable\n        with:\n          components: clippy\n",
133            );
134            out.push_str("      - run: cargo clippy --all-targets -- -D warnings\n");
135        }
136        if self.fmt {
137            out.push_str("  fmt:\n    runs-on: ubuntu-latest\n    steps:\n");
138            out.push_str("      - uses: actions/checkout@v5\n");
139            out.push_str(
140                "      - uses: dtolnay/rust-toolchain@stable\n        with:\n          components: rustfmt\n",
141            );
142            out.push_str("      - run: cargo fmt --all -- --check\n");
143        }
144        if let Some(msrv) = &self.msrv {
145            out.push_str("  msrv:\n    runs-on: ubuntu-latest\n    steps:\n");
146            out.push_str("      - uses: actions/checkout@v5\n");
147            out.push_str(&format!("      - uses: dtolnay/rust-toolchain@{msrv}\n"));
148            out.push_str("      - run: cargo build --all-features\n");
149        }
150        out
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn default_generates_a_test_job() {
160        let yaml = Generator::new().generate();
161        assert!(yaml.contains("jobs:"));
162        assert!(yaml.contains("test:"));
163        assert!(yaml.contains("actions/checkout@v5"));
164    }
165
166    #[test]
167    fn clippy_job_added_when_requested() {
168        let yaml = Generator::new().with_clippy().generate();
169        assert!(yaml.contains("clippy:"));
170    }
171
172    #[test]
173    fn fmt_job_added_when_requested() {
174        let yaml = Generator::new().with_fmt().generate();
175        assert!(yaml.contains("fmt:"));
176    }
177
178    #[test]
179    fn msrv_job_uses_pinned_toolchain() {
180        let yaml = Generator::new().with_msrv("1.85").generate();
181        assert!(yaml.contains("rust-toolchain@1.85"));
182    }
183}