use std::path::Path;
use std::{fmt, fs};
use garde::Validate;
use serde::{Deserialize, Serialize};
use crate::input::{Input, InputError};
use crate::served::{self, Served};
pub const REPO_FILE: &str = ".ctl/rust.yaml";
const MARKER: &str = "# ctl-core baseline:";
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize, Validate)]
#[serde(deny_unknown_fields, default)]
pub struct Config {
#[garde(skip)]
pub layout: Layout,
#[garde(skip)]
pub published: bool,
#[garde(skip)]
pub tokio: bool,
#[garde(dive)]
pub clippy: Clippy,
#[garde(dive)]
pub deny: Deny,
#[garde(dive)]
pub own: Vec<Owned>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Layout {
#[default]
Crate,
Workspace,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize, Validate)]
#[serde(deny_unknown_fields, default)]
pub struct Clippy {
#[garde(dive)]
pub disallowed_methods: Vec<Disallowed>,
#[garde(dive)]
pub disallowed_types: Vec<Disallowed>,
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Validate)]
#[serde(deny_unknown_fields)]
pub struct Disallowed {
#[garde(custom(one_line))]
pub path: String,
#[garde(custom(one_line))]
pub reason: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize, Validate)]
#[serde(deny_unknown_fields, default)]
pub struct Deny {
#[garde(dive)]
pub licenses: Vec<License>,
#[garde(dive)]
pub bans: Vec<Ban>,
#[garde(dive)]
pub skip: Vec<Skip>,
#[garde(dive)]
pub ignore: Vec<Advisory>,
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Validate)]
#[serde(deny_unknown_fields)]
pub struct License {
#[garde(custom(one_line))]
pub license: String,
#[garde(custom(one_line))]
pub reason: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Validate)]
#[serde(deny_unknown_fields)]
pub struct Ban {
#[serde(rename = "crate")]
#[garde(custom(one_line))]
pub name: String,
#[serde(default)]
#[garde(inner(custom(one_line)))]
pub wrappers: Vec<String>,
#[garde(custom(one_line))]
pub reason: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Validate)]
#[serde(deny_unknown_fields)]
pub struct Skip {
#[serde(rename = "crate")]
#[garde(custom(one_line))]
pub name: String,
#[garde(custom(one_line))]
pub reason: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Validate)]
#[serde(deny_unknown_fields)]
pub struct Advisory {
#[garde(custom(one_line))]
pub id: String,
#[garde(custom(one_line))]
pub reason: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Validate)]
#[serde(deny_unknown_fields)]
pub struct Owned {
#[garde(skip)]
pub file: File,
#[garde(custom(one_line))]
pub reason: String,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
pub enum File {
#[serde(rename = "rust-toolchain.toml")]
RustToolchain,
#[serde(rename = "rustfmt.toml")]
Rustfmt,
#[serde(rename = "clippy.toml")]
Clippy,
#[serde(rename = "deny.toml")]
Deny,
#[serde(rename = "lefthook.yml")]
Lefthook,
#[serde(rename = "Cargo.toml")]
Cargo,
#[serde(rename = "mise.dev.toml")]
MiseDev,
}
impl File {
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::RustToolchain => "rust-toolchain.toml",
Self::Rustfmt => "rustfmt.toml",
Self::Clippy => "clippy.toml",
Self::Deny => "deny.toml",
Self::Lefthook => "lefthook.yml",
Self::Cargo => "Cargo.toml",
Self::MiseDev => "mise.dev.toml",
}
}
const fn block(self) -> Option<&'static str> {
match self {
Self::Cargo => Some("lints"),
Self::MiseDev => Some("tools"),
Self::RustToolchain | Self::Rustfmt | Self::Clippy | Self::Deny | Self::Lefthook => {
None
}
}
}
const fn template(self) -> &'static str {
match self {
Self::RustToolchain => include_str!("../templates/baseline/rust-toolchain.toml.jinja"),
Self::Rustfmt => include_str!("../templates/baseline/rustfmt.toml.jinja"),
Self::Clippy => include_str!("../templates/baseline/clippy.toml.jinja"),
Self::Deny => include_str!("../templates/baseline/deny.toml.jinja"),
Self::Lefthook => include_str!("../templates/baseline/lefthook.yml.jinja"),
Self::Cargo => include_str!("../templates/baseline/Cargo.toml.jinja"),
Self::MiseDev => include_str!("../templates/baseline/mise.dev.toml.jinja"),
}
}
}
const FILES: [File; 7] = [
File::RustToolchain,
File::Rustfmt,
File::Clippy,
File::Deny,
File::Lefthook,
File::Cargo,
File::MiseDev,
];
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Rendered {
pub served: Vec<Served>,
pub owned: Vec<Owned>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BaselineError {
pub file: &'static str,
pub message: String,
}
impl fmt::Display for BaselineError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}: {}", self.file, self.message)
}
}
impl std::error::Error for BaselineError {}
pub fn load(root: &Path) -> Result<Config, InputError> {
Input::read(&root.join(REPO_FILE))?.load()
}
pub fn render(root: &Path, config: &Config) -> Result<Rendered, BaselineError> {
let mut served = Vec::new();
for file in FILES {
if config.own.iter().any(|owned| owned.file == file) {
continue;
}
let failed = |message: String| BaselineError {
file: file.name(),
message,
};
let template = format!("{}.jinja", file.name());
let rendered = served::render(Path::new(&template), file.template(), ".jinja", config)
.map_err(|error| failed(error.message))?;
let rendered = match file.block() {
None => rendered,
Some(block) => {
let current = fs::read_to_string(root.join(file.name()))
.map_err(|error| failed(error.to_string()))?;
let body = splice(¤t, block, rendered.body()).map_err(failed)?;
rendered.with_body(body)
}
};
served.push(rendered);
}
Ok(Rendered {
served,
owned: config.own.clone(),
})
}
fn splice(current: &str, block: &str, body: &str) -> Result<String, String> {
let begin = format!("{MARKER} begin {block}");
let end = format!("{MARKER} end {block}");
let mut start = None;
let mut crlf = false;
let mut offset = 0;
for line in current.split_inclusive('\n') {
let text = line.trim_end();
if let Some(from) = start {
if text == end {
let body = if crlf {
body.replace('\n', "\r\n")
} else {
body.to_owned()
};
return Ok(format!("{}{body}{}", ¤t[..from], ¤t[offset..]));
}
} else if text == begin {
start = Some(offset + line.len());
crlf = line.ends_with("\r\n");
}
offset += line.len();
}
Err(if start.is_some() {
format!("has no `{end}` line after `{begin}`")
} else {
format!("has no `{begin}` line")
})
}
fn one_line<C>(value: &str, _: &C) -> garde::Result {
if value.is_empty() {
return Err(garde::Error::new("cannot be empty"));
}
if value
.chars()
.any(|character| character.is_control() || character == '"' || character == '\\')
{
return Err(garde::Error::new(
"must be one line without quotes or backslashes, so it fits a TOML string",
));
}
Ok(())
}