use std::collections::BTreeMap;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use serde::Deserialize;
use crate::Error;
use crate::ErrorKind;
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
pub header: HeaderConfig,
#[serde(default)]
pub files: FilesConfig,
#[serde(default)]
pub props: BTreeMap<String, toml::Value>,
#[serde(default)]
pub git: GitConfig,
#[serde(default)]
pub styles: BTreeMap<String, StyleConfig>,
#[serde(default)]
pub rules: Vec<RuleConfig>,
}
impl Config {
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
let path = path.as_ref();
let source = fs::read_to_string(path).map_err(|err| {
Error::new(
ErrorKind::Unexpected,
format!("cannot read config file {}", path.display()),
)
.with_source(err)
})?;
let mut config = toml::from_str::<Self>(&source).map_err(|err| {
Error::new(
ErrorKind::ConfigInvalid,
format!("cannot parse config file {}", path.display()),
)
.with_source(err)
})?;
let path = path.canonicalize().map_err(|err| {
Error::new(
ErrorKind::Unexpected,
format!("cannot resolve config file {}", path.display()),
)
.with_source(err)
})?;
let directory = path.parent().ok_or_else(|| {
Error::new(
ErrorKind::ConfigInvalid,
"config file has no parent directory",
)
})?;
if config.files.root.is_relative() {
config.files.root = directory.join(&config.files.root);
}
if let Some(header_path) = &mut config.header.path
&& header_path.is_relative()
{
*header_path = directory.join(&*header_path);
}
Ok(config)
}
pub fn validate(&self) -> Result<(), Error> {
let mut validator = Validator::default();
validator.header(&self.header);
validator.files(&self.files);
validator.styles(&self.styles);
validator.rules(&self.rules);
if validator.issues.is_empty() {
return Ok(());
}
let mut message = String::from("config validation failed:");
for issue in validator.issues {
message.push_str("\n- ");
message.push_str(&issue);
}
Err(Error::new(ErrorKind::ConfigInvalid, message))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HeaderConfig {
pub builtin: Option<String>,
pub path: Option<PathBuf>,
pub text: Option<String>,
#[serde(default = "default_keywords")]
pub keywords: Vec<String>,
}
fn default_keywords() -> Vec<String> {
vec!["copyright".to_owned()]
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct FilesConfig {
pub root: PathBuf,
pub includes: Vec<String>,
pub excludes: Vec<String>,
}
impl Default for FilesConfig {
fn default() -> Self {
Self {
root: PathBuf::from("."),
includes: Vec::new(),
excludes: Vec::new(),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
pub enum FeatureMode {
#[serde(rename = "disable")]
Disable,
#[serde(rename = "auto")]
#[default]
Auto,
#[serde(rename = "enable")]
Enable,
}
impl FeatureMode {
pub(crate) fn combine(self, other: Self) -> Self {
match (self, other) {
(Self::Enable, _) | (_, Self::Enable) => Self::Enable,
(Self::Auto, _) | (_, Self::Auto) => Self::Auto,
(Self::Disable, Self::Disable) => Self::Disable,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct GitConfig {
pub ignore: FeatureMode,
pub file_attrs: FeatureMode,
}
impl Default for GitConfig {
fn default() -> Self {
Self {
ignore: FeatureMode::Auto,
file_attrs: FeatureMode::Disable,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RuleConfig {
#[serde(default)]
pub extensions: Vec<String>,
#[serde(default)]
pub filenames: Vec<String>,
pub style_out: String,
#[serde(default)]
pub styles_in: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(tag = "kind", deny_unknown_fields)]
pub enum StyleConfig {
#[serde(rename = "line")]
Line {
#[serde(default)]
prefix: String,
#[serde(default)]
suffix: String,
#[serde(default)]
pad_lines: bool,
},
#[serde(rename = "block")]
Block {
start: String,
#[serde(default)]
prefix: String,
#[serde(default)]
suffix: String,
end: String,
},
}
#[derive(Default)]
struct Validator {
issues: Vec<String>,
}
impl Validator {
fn issue(&mut self, path: impl Into<String>, message: impl Into<String>) {
self.issues
.push(format!("{}: {}", path.into(), message.into()));
}
fn header(&mut self, header: &HeaderConfig) {
let source_count = usize::from(header.builtin.is_some())
+ usize::from(header.path.is_some())
+ usize::from(header.text.is_some());
if source_count != 1 {
self.issue(
"header",
"exactly one of `builtin`, `path`, or `text` must be set",
);
}
if let Some(value) = &header.builtin {
self.non_blank("header.builtin", value);
self.no_nul("header.builtin", value);
}
if let Some(value) = &header.path
&& value.as_os_str().is_empty()
{
self.issue("header.path", "header path must not be empty");
}
if let Some(value) = &header.text {
self.non_blank("header.text", value);
self.no_nul("header.text", value);
}
if header.keywords.is_empty() {
self.issue(
"header.keywords",
"at least one keyword is required to distinguish a header from an ordinary comment",
);
}
let mut seen = HashMap::<String, usize>::new();
for (index, keyword) in header.keywords.iter().enumerate() {
let path = format!("header.keywords[{index}]");
self.non_blank(&path, keyword);
let folded = keyword.to_lowercase();
if let Some(first) = seen.get(&folded) {
self.issue(
path,
format!("duplicates `header.keywords[{first}]` case-insensitively"),
);
} else {
seen.insert(folded, index);
}
}
}
fn files(&mut self, files: &FilesConfig) {
if files.root.as_os_str().is_empty() {
self.issue("files.root", "file root must not be empty");
}
self.patterns("files.includes", &files.includes);
self.patterns("files.excludes", &files.excludes);
}
fn patterns(&mut self, path: &str, patterns: &[String]) {
for (index, pattern) in patterns.iter().enumerate() {
let path = format!("{path}[{index}]");
self.non_blank(&path, pattern);
self.no_nul(&path, pattern);
if pattern.starts_with('!') {
self.issue(
path,
"negation is not accepted because includes and excludes are separate lists",
);
}
}
}
fn styles(&mut self, styles: &BTreeMap<String, StyleConfig>) {
for (name, style) in styles {
let path = format!("styles.{name}");
self.non_blank(&path, name);
self.no_nul(&path, name);
match style {
StyleConfig::Line {
prefix,
suffix,
pad_lines,
} => {
self.token(&format!("{path}.prefix"), prefix);
self.token(&format!("{path}.suffix"), suffix);
if prefix.trim().is_empty() && suffix.trim().is_empty() {
self.issue(&path, "line style needs a non-whitespace prefix or suffix");
}
if *pad_lines && suffix.is_empty() {
self.issue(
format!("{path}.pad_lines"),
"line padding requires a non-empty suffix",
);
}
}
StyleConfig::Block {
start,
prefix,
suffix,
end,
} => {
self.token(&format!("{path}.start"), start);
self.token(&format!("{path}.prefix"), prefix);
self.token(&format!("{path}.suffix"), suffix);
self.token(&format!("{path}.end"), end);
if start.trim().is_empty() {
self.issue(format!("{path}.start"), "block start must not be blank");
}
if end.trim().is_empty() {
self.issue(format!("{path}.end"), "block end must not be blank");
}
}
}
}
}
fn rules(&mut self, rules: &[RuleConfig]) {
for (index, rule) in rules.iter().enumerate() {
let path = format!("rules[{index}]");
if rule.extensions.is_empty() && rule.filenames.is_empty() {
self.issue(
&path,
"at least one extension or filename must be configured",
);
}
for (item, extension) in rule.extensions.iter().enumerate() {
let item_path = format!("{path}.extensions[{item}]");
self.non_blank(&item_path, extension);
if extension.starts_with('.') {
self.issue(&item_path, "extension must not start with `.`");
}
if extension.contains(['/', '\\']) {
self.issue(&item_path, "extension must not contain a path separator");
}
}
for (item, filename) in rule.filenames.iter().enumerate() {
let item_path = format!("{path}.filenames[{item}]");
self.non_blank(&item_path, filename);
if filename.contains(['/', '\\']) {
self.issue(&item_path, "filename must not contain a path separator");
}
}
self.non_blank(&format!("{path}.style_out"), &rule.style_out);
if !rule.styles_in.is_empty() && !rule.styles_in.contains(&rule.style_out) {
self.issue(
format!("{path}.styles_in"),
"must include `style_out` when explicitly configured",
);
}
for (item, style) in rule.styles_in.iter().enumerate() {
self.non_blank(&format!("{path}.styles_in[{item}]"), style);
}
}
}
fn non_blank(&mut self, path: &str, value: &str) {
if value.trim().is_empty() {
self.issue(path, "must not be blank");
}
}
fn no_nul(&mut self, path: &str, value: &str) {
if value.contains('\0') {
self.issue(path, "must not contain a NUL byte");
}
}
fn token(&mut self, path: &str, value: &str) {
self.no_nul(path, value);
if value.contains(['\r', '\n']) {
self.issue(path, "style token must not contain a line ending");
}
}
}