Skip to main content

ctl_core/
baseline.rs

1//! The Rust configuration every ctl repository shares, rendered from
2//! templates embedded in this release.
3//!
4//! A repository declares what sets it apart in [`REPO_FILE`]. [`render`]
5//! returns each served file whole: five files belong wholly to the baseline,
6//! and in `Cargo.toml` and `mise.dev.toml` only the text between
7//! `# ctl-core baseline: begin <block>` and `# ctl-core baseline: end <block>`
8//! does, so the rest of those files stays the repository's. A runner writes
9//! or compares the results; this module writes nothing.
10//!
11//! A repository changes the baseline in two ways only: it appends entries,
12//! each with its reason, or it owns a whole file, which [`render`] then skips
13//! and reports.
14
15use std::path::Path;
16use std::{fmt, fs};
17
18use garde::Validate;
19use serde::{Deserialize, Serialize};
20
21use crate::input::{Input, InputError};
22use crate::served::{self, Served};
23
24/// Where a repository declares its baseline, relative to its root.
25pub const REPO_FILE: &str = ".ctl/rust.yaml";
26
27/// The marker that opens and closes a served block.
28const MARKER: &str = "# ctl-core baseline:";
29
30/// What a repository declares in [`REPO_FILE`]. Every field is optional.
31#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize, Validate)]
32#[serde(deny_unknown_fields, default)]
33pub struct Config {
34    /// One crate, or a workspace. Unsaid means one crate.
35    #[garde(skip)]
36    pub layout: Layout,
37    /// Whether the crate goes to `crates.io`, which adds cargo-semver-checks.
38    #[garde(skip)]
39    pub published: bool,
40    /// Whether the crate uses tokio, which adds its lock guards and the
41    /// unbounded-channel ban to `clippy.toml`.
42    #[garde(skip)]
43    pub tokio: bool,
44    /// Entries appended to `clippy.toml`.
45    #[garde(dive)]
46    pub clippy: Clippy,
47    /// Entries appended to `deny.toml`.
48    #[garde(dive)]
49    pub deny: Deny,
50    /// Files the repository keeps for itself.
51    #[garde(dive)]
52    pub own: Vec<Owned>,
53}
54
55/// How the crates sit in the repository.
56#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
57#[serde(rename_all = "lowercase")]
58pub enum Layout {
59    /// One package: lints go under `[lints]`.
60    #[default]
61    Crate,
62    /// A workspace: lints go under `[workspace.lints]`.
63    Workspace,
64}
65
66/// Entries appended to `clippy.toml`.
67#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize, Validate)]
68#[serde(deny_unknown_fields, default)]
69pub struct Clippy {
70    /// Added to `disallowed-methods`.
71    #[garde(dive)]
72    pub disallowed_methods: Vec<Disallowed>,
73    /// Added to `disallowed-types`.
74    #[garde(dive)]
75    pub disallowed_types: Vec<Disallowed>,
76}
77
78/// A path clippy refuses, and why.
79#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Validate)]
80#[serde(deny_unknown_fields)]
81pub struct Disallowed {
82    /// The method or type path.
83    #[garde(custom(one_line))]
84    pub path: String,
85    /// Why, as clippy will print it.
86    #[garde(custom(one_line))]
87    pub reason: String,
88}
89
90/// Entries appended to `deny.toml`.
91#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize, Validate)]
92#[serde(deny_unknown_fields, default)]
93pub struct Deny {
94    /// Licenses allowed beyond the baseline's.
95    #[garde(dive)]
96    pub licenses: Vec<License>,
97    /// Crates banned beyond the baseline's.
98    #[garde(dive)]
99    pub bans: Vec<Ban>,
100    /// Duplicate versions tolerated.
101    #[garde(dive)]
102    pub skip: Vec<Skip>,
103    /// Advisories ignored.
104    #[garde(dive)]
105    pub ignore: Vec<Advisory>,
106}
107
108/// A license allowed, and which dependency needs it.
109#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Validate)]
110#[serde(deny_unknown_fields)]
111pub struct License {
112    /// The SPDX identifier.
113    #[garde(custom(one_line))]
114    pub license: String,
115    /// Which dependency needs it.
116    #[garde(custom(one_line))]
117    pub reason: String,
118}
119
120/// A crate banned, optionally except through named wrappers.
121#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Validate)]
122#[serde(deny_unknown_fields)]
123pub struct Ban {
124    /// The crate name.
125    #[serde(rename = "crate")]
126    #[garde(custom(one_line))]
127    pub name: String,
128    /// The only crates that may depend on it directly. Unsaid means none.
129    #[serde(default)]
130    #[garde(inner(custom(one_line)))]
131    pub wrappers: Vec<String>,
132    /// Why.
133    #[garde(custom(one_line))]
134    pub reason: String,
135}
136
137/// A duplicate version tolerated, and when it can go.
138#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Validate)]
139#[serde(deny_unknown_fields)]
140pub struct Skip {
141    /// The crate, optionally `name@version`.
142    #[serde(rename = "crate")]
143    #[garde(custom(one_line))]
144    pub name: String,
145    /// Why, and the condition for removing it.
146    #[garde(custom(one_line))]
147    pub reason: String,
148}
149
150/// An advisory ignored, and when it can go.
151#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Validate)]
152#[serde(deny_unknown_fields)]
153pub struct Advisory {
154    /// The RUSTSEC id.
155    #[garde(custom(one_line))]
156    pub id: String,
157    /// Why, and the condition for removing it.
158    #[garde(custom(one_line))]
159    pub reason: String,
160}
161
162/// A served file the repository keeps for itself.
163#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Validate)]
164#[serde(deny_unknown_fields)]
165pub struct Owned {
166    /// The file.
167    #[garde(skip)]
168    pub file: File,
169    /// Why the repository keeps it.
170    #[garde(custom(one_line))]
171    pub reason: String,
172}
173
174/// A file the baseline serves, whole or by one block.
175#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
176pub enum File {
177    /// The pinned stable, for editors and plain cargo.
178    #[serde(rename = "rust-toolchain.toml")]
179    RustToolchain,
180    /// Formatting options.
181    #[serde(rename = "rustfmt.toml")]
182    Rustfmt,
183    /// Clippy configuration.
184    #[serde(rename = "clippy.toml")]
185    Clippy,
186    /// cargo-deny policy.
187    #[serde(rename = "deny.toml")]
188    Deny,
189    /// Git hooks.
190    #[serde(rename = "lefthook.yml")]
191    Lefthook,
192    /// The `lints` block.
193    #[serde(rename = "Cargo.toml")]
194    Cargo,
195    /// The `tools` block.
196    #[serde(rename = "mise.dev.toml")]
197    MiseDev,
198}
199
200impl File {
201    /// The file's name at the repository root.
202    #[must_use]
203    pub const fn name(self) -> &'static str {
204        match self {
205            Self::RustToolchain => "rust-toolchain.toml",
206            Self::Rustfmt => "rustfmt.toml",
207            Self::Clippy => "clippy.toml",
208            Self::Deny => "deny.toml",
209            Self::Lefthook => "lefthook.yml",
210            Self::Cargo => "Cargo.toml",
211            Self::MiseDev => "mise.dev.toml",
212        }
213    }
214
215    /// The block this release serves inside the file, or `None` when it
216    /// serves the whole file.
217    const fn block(self) -> Option<&'static str> {
218        match self {
219            Self::Cargo => Some("lints"),
220            Self::MiseDev => Some("tools"),
221            Self::RustToolchain | Self::Rustfmt | Self::Clippy | Self::Deny | Self::Lefthook => {
222                None
223            }
224        }
225    }
226
227    /// The embedded template.
228    const fn template(self) -> &'static str {
229        match self {
230            Self::RustToolchain => include_str!("../templates/baseline/rust-toolchain.toml.jinja"),
231            Self::Rustfmt => include_str!("../templates/baseline/rustfmt.toml.jinja"),
232            Self::Clippy => include_str!("../templates/baseline/clippy.toml.jinja"),
233            Self::Deny => include_str!("../templates/baseline/deny.toml.jinja"),
234            Self::Lefthook => include_str!("../templates/baseline/lefthook.yml.jinja"),
235            Self::Cargo => include_str!("../templates/baseline/Cargo.toml.jinja"),
236            Self::MiseDev => include_str!("../templates/baseline/mise.dev.toml.jinja"),
237        }
238    }
239}
240
241/// Every file, in the order [`render`] returns them.
242const FILES: [File; 7] = [
243    File::RustToolchain,
244    File::Rustfmt,
245    File::Clippy,
246    File::Deny,
247    File::Lefthook,
248    File::Cargo,
249    File::MiseDev,
250];
251
252/// What [`render`] produced for a repository.
253#[derive(Clone, Debug, PartialEq, Eq)]
254pub struct Rendered {
255    /// Each served file, whole, ready to write or to compare.
256    pub served: Vec<Served>,
257    /// The files the repository keeps, with its reasons.
258    pub owned: Vec<Owned>,
259}
260
261/// Why the baseline could not be rendered, naming the file.
262#[derive(Clone, Debug, PartialEq, Eq)]
263pub struct BaselineError {
264    /// The file at the repository root.
265    pub file: &'static str,
266    /// What went wrong.
267    pub message: String,
268}
269
270impl fmt::Display for BaselineError {
271    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
272        write!(formatter, "{}: {}", self.file, self.message)
273    }
274}
275
276impl std::error::Error for BaselineError {}
277
278/// Read and validate `root`'s [`REPO_FILE`].
279pub fn load(root: &Path) -> Result<Config, InputError> {
280    Input::read(&root.join(REPO_FILE))?.load()
281}
282
283/// Render every file `config` does not own. A block file is read from
284/// `root` and returned whole, with only its block replaced.
285pub fn render(root: &Path, config: &Config) -> Result<Rendered, BaselineError> {
286    let mut served = Vec::new();
287    for file in FILES {
288        if config.own.iter().any(|owned| owned.file == file) {
289            continue;
290        }
291        let failed = |message: String| BaselineError {
292            file: file.name(),
293            message,
294        };
295        let template = format!("{}.jinja", file.name());
296        let rendered = served::render(Path::new(&template), file.template(), ".jinja", config)
297            .map_err(|error| failed(error.message))?;
298        let rendered = match file.block() {
299            None => rendered,
300            Some(block) => {
301                let current = fs::read_to_string(root.join(file.name()))
302                    .map_err(|error| failed(error.to_string()))?;
303                let body = splice(&current, block, rendered.body()).map_err(failed)?;
304                rendered.with_body(body)
305            }
306        };
307        served.push(rendered);
308    }
309    Ok(Rendered {
310        served,
311        owned: config.own.clone(),
312    })
313}
314
315/// `current` with the text between `block`'s marker lines replaced by
316/// `body`. A marker is a whole line, compared without trailing whitespace,
317/// so a CRLF checkout still matches and `end lintsX` does not. The body
318/// takes the begin marker's line ending, so the file keeps one style.
319fn splice(current: &str, block: &str, body: &str) -> Result<String, String> {
320    let begin = format!("{MARKER} begin {block}");
321    let end = format!("{MARKER} end {block}");
322    let mut start = None;
323    let mut crlf = false;
324    let mut offset = 0;
325    for line in current.split_inclusive('\n') {
326        let text = line.trim_end();
327        if let Some(from) = start {
328            if text == end {
329                let body = if crlf {
330                    body.replace('\n', "\r\n")
331                } else {
332                    body.to_owned()
333                };
334                return Ok(format!("{}{body}{}", &current[..from], &current[offset..]));
335            }
336        } else if text == begin {
337            start = Some(offset + line.len());
338            crlf = line.ends_with("\r\n");
339        }
340        offset += line.len();
341    }
342    Err(if start.is_some() {
343        format!("has no `{end}` line after `{begin}`")
344    } else {
345        format!("has no `{begin}` line")
346    })
347}
348
349/// A value that sits inside a TOML string or comment on one line.
350fn one_line<C>(value: &str, _: &C) -> garde::Result {
351    if value.is_empty() {
352        return Err(garde::Error::new("cannot be empty"));
353    }
354    if value
355        .chars()
356        .any(|character| character.is_control() || character == '"' || character == '\\')
357    {
358        return Err(garde::Error::new(
359            "must be one line without quotes or backslashes, so it fits a TOML string",
360        ));
361    }
362    Ok(())
363}