1use 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
24pub const REPO_FILE: &str = ".ctl/rust.yaml";
26
27const MARKER: &str = "# ctl-core baseline:";
29
30#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize, Validate)]
32#[serde(deny_unknown_fields, default)]
33pub struct Config {
34 #[garde(skip)]
36 pub layout: Layout,
37 #[garde(skip)]
39 pub published: bool,
40 #[garde(skip)]
43 pub tokio: bool,
44 #[garde(dive)]
46 pub clippy: Clippy,
47 #[garde(dive)]
49 pub deny: Deny,
50 #[garde(dive)]
52 pub own: Vec<Owned>,
53}
54
55#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
57#[serde(rename_all = "lowercase")]
58pub enum Layout {
59 #[default]
61 Crate,
62 Workspace,
64}
65
66#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize, Validate)]
68#[serde(deny_unknown_fields, default)]
69pub struct Clippy {
70 #[garde(dive)]
72 pub disallowed_methods: Vec<Disallowed>,
73 #[garde(dive)]
75 pub disallowed_types: Vec<Disallowed>,
76}
77
78#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Validate)]
80#[serde(deny_unknown_fields)]
81pub struct Disallowed {
82 #[garde(custom(one_line))]
84 pub path: String,
85 #[garde(custom(one_line))]
87 pub reason: String,
88}
89
90#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize, Validate)]
92#[serde(deny_unknown_fields, default)]
93pub struct Deny {
94 #[garde(dive)]
96 pub licenses: Vec<License>,
97 #[garde(dive)]
99 pub bans: Vec<Ban>,
100 #[garde(dive)]
102 pub skip: Vec<Skip>,
103 #[garde(dive)]
105 pub ignore: Vec<Advisory>,
106}
107
108#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Validate)]
110#[serde(deny_unknown_fields)]
111pub struct License {
112 #[garde(custom(one_line))]
114 pub license: String,
115 #[garde(custom(one_line))]
117 pub reason: String,
118}
119
120#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Validate)]
122#[serde(deny_unknown_fields)]
123pub struct Ban {
124 #[serde(rename = "crate")]
126 #[garde(custom(one_line))]
127 pub name: String,
128 #[serde(default)]
130 #[garde(inner(custom(one_line)))]
131 pub wrappers: Vec<String>,
132 #[garde(custom(one_line))]
134 pub reason: String,
135}
136
137#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Validate)]
139#[serde(deny_unknown_fields)]
140pub struct Skip {
141 #[serde(rename = "crate")]
143 #[garde(custom(one_line))]
144 pub name: String,
145 #[garde(custom(one_line))]
147 pub reason: String,
148}
149
150#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Validate)]
152#[serde(deny_unknown_fields)]
153pub struct Advisory {
154 #[garde(custom(one_line))]
156 pub id: String,
157 #[garde(custom(one_line))]
159 pub reason: String,
160}
161
162#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Validate)]
164#[serde(deny_unknown_fields)]
165pub struct Owned {
166 #[garde(skip)]
168 pub file: File,
169 #[garde(custom(one_line))]
171 pub reason: String,
172}
173
174#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
176pub enum File {
177 #[serde(rename = "rust-toolchain.toml")]
179 RustToolchain,
180 #[serde(rename = "rustfmt.toml")]
182 Rustfmt,
183 #[serde(rename = "clippy.toml")]
185 Clippy,
186 #[serde(rename = "deny.toml")]
188 Deny,
189 #[serde(rename = "lefthook.yml")]
191 Lefthook,
192 #[serde(rename = "Cargo.toml")]
194 Cargo,
195 #[serde(rename = "mise.dev.toml")]
197 MiseDev,
198}
199
200impl File {
201 #[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 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 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
241const 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#[derive(Clone, Debug, PartialEq, Eq)]
254pub struct Rendered {
255 pub served: Vec<Served>,
257 pub owned: Vec<Owned>,
259}
260
261#[derive(Clone, Debug, PartialEq, Eq)]
263pub struct BaselineError {
264 pub file: &'static str,
266 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
278pub fn load(root: &Path) -> Result<Config, InputError> {
280 Input::read(&root.join(REPO_FILE))?.load()
281}
282
283pub 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(¤t, 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
315fn 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}{}", ¤t[..from], ¤t[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
349fn 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}