use crate::config_error::ConfigError;
use crate::config_rules_file::{check_rules_file, rules_file_refusal_reason};
use crate::config_schema::{
ForbiddenStringsOptions, MarkdownAutofixOptions, MarkdownRule, PolicyConfig, RulesFileEntry,
markdown_rule_from_name,
};
use crate::config_values::{boolean, member_keys, member_path, strings, text, wrong_kind};
use crate::policy_registry::{
POLICY_REGISTRY, PolicyDescriptor, PolicyId, Severity, policy_by_name, severity_from_name,
};
use monochromatic_jsonc_edit::JsoncValue;
fn known_policy_ids() -> String {
let mut result: String = String::new();
for descriptor in POLICY_REGISTRY {
if !result.is_empty() {
result.push_str(", ");
}
result.push_str(descriptor.name);
}
return result;
}
fn severity(value: &JsoncValue, path: &str) -> Result<Severity, ConfigError> {
let word: String = text(value, path)?;
let Some(parsed) = severity_from_name(word.as_str()) else {
return Err(ConfigError::new(
format!(
"Configuration key {path} has an invalid severity {word:?}; use \"off\", \"warn\" or \"error\"."
)
.as_str(),
));
};
return Ok(parsed);
}
fn checked_rules_file(name: String, key_path: &str) -> Result<String, ConfigError> {
if let Err(refusal) = check_rules_file(name.as_str()) {
return Err(ConfigError::new(
format!(
"Configuration key {key_path} must name a file relative to the \
repository's top level that stays inside it, but the value {}.",
rules_file_refusal_reason(refusal)
)
.as_str(),
));
}
return Ok(name);
}
fn rules_file_entry(value: &JsoncValue, path: &str) -> Result<RulesFileEntry, ConfigError> {
if value.text_units().is_some() {
let name: String = text(value, path)?;
return Ok(RulesFileEntry { path: checked_rules_file(name, path)?, optional: false });
}
let Some(entries) = value.entries() else {
return Err(wrong_kind(
value,
path,
"a file name or an object with \"path\" and an optional \"optional\"",
));
};
let keys: Vec<String> = member_keys(entries, path)?;
let mut file: Option<String> = None;
let mut optional: bool = false;
for (index, key) in keys.iter().enumerate() {
let key_path: String = member_path(path, key.as_str());
let member: &JsoncValue = &entries[index].value;
if key == "path" {
let name: String = text(member, key_path.as_str())?;
file = Some(checked_rules_file(name, key_path.as_str())?);
} else if key == "optional" {
optional = boolean(member, key_path.as_str())?;
} else {
return Err(ConfigError::new(
format!("Unknown configuration key: {key_path}. Accepted keys: path, optional.").as_str(),
));
}
}
let Some(found) = file else {
return Err(ConfigError::new(
format!("Configuration key {path} must have a \"path\" key naming the rules file.").as_str(),
));
};
return Ok(RulesFileEntry { path: found, optional });
}
fn rules_files(value: &JsoncValue, path: &str) -> Result<Vec<RulesFileEntry>, ConfigError> {
if value.text_units().is_some() {
return Ok(vec![rules_file_entry(value, path)?]);
}
let Some(elements) = value.elements() else {
return Err(wrong_kind(
value,
path,
"a file name, or an array of file names and objects with \"path\" and an optional \"optional\"",
));
};
if elements.is_empty() {
return Err(ConfigError::new(
format!(
"Configuration key {path} must name at least one file; remove the key to use \
FORBIDDEN_STRINGS_RULES or the default file."
)
.as_str(),
));
}
let mut result: Vec<RulesFileEntry> = Vec::<RulesFileEntry>::with_capacity(elements.len());
for (index, element) in elements.iter().enumerate() {
let element_path: String = format!("{path}[{index}]");
let entry: RulesFileEntry = rules_file_entry(element, element_path.as_str())?;
if let Some(first) = result.iter().position(|earlier| return earlier.path == entry.path) {
return Err(ConfigError::new(
format!(
"Configuration key {element_path} names the same file as {path}[{first}]; \
name each rules file once."
)
.as_str(),
));
}
result.push(entry);
}
return Ok(result);
}
fn forbidden_strings_options(
value: &JsoncValue,
path: &str,
) -> Result<ForbiddenStringsOptions, ConfigError> {
let Some(entries) = value.entries() else {
return Err(wrong_kind(value, path, "an options object"));
};
let keys: Vec<String> = member_keys(entries, path)?;
let mut options: ForbiddenStringsOptions = PolicyConfig::defaults().forbidden_strings;
for (index, key) in keys.iter().enumerate() {
let key_path: String = member_path(path, key.as_str());
let member: &JsoncValue = &entries[index].value;
if key == "builtinRules" {
options.builtin_rules = boolean(member, key_path.as_str())?;
} else if key == "rulesFile" {
options.rules_files = Some(rules_files(member, key_path.as_str())?);
} else if key == "executable" {
return Err(ConfigError::new(
format!(
"Configuration key {key_path} is retired: cli-git runs its bundled \
forbidden-strings scanner and configuration cannot select an executable. \
Remove the key."
)
.as_str(),
));
} else {
return Err(ConfigError::new(
format!(
"Unknown configuration key: {key_path}. Accepted options: builtinRules, rulesFile."
)
.as_str(),
));
}
}
return Ok(options);
}
fn markdown_rules(value: &JsoncValue, path: &str) -> Result<Vec<MarkdownRule>, ConfigError> {
let names: Vec<String> = strings(value, path)?;
if names.is_empty() {
return Err(ConfigError::new(
format!(
"Configuration key {path} must name at least one rule; set the policy's \
severity to \"off\" to disable it."
)
.as_str(),
));
}
let mut rules: Vec<MarkdownRule> = Vec::<MarkdownRule>::with_capacity(names.len());
for (index, name) in names.iter().enumerate() {
let element_path: String = format!("{path}[{index}]");
let Some(rule) = markdown_rule_from_name(name.as_str()) else {
return Err(ConfigError::new(
format!(
"Configuration key {element_path} names an unknown Markdown rule {name:?}; \
the shipped rule is \"lfs-image-url\"."
)
.as_str(),
));
};
if rules.contains(&rule) {
return Err(ConfigError::new(
format!(
"Configuration key {element_path} repeats the Markdown rule {name:?}; \
list each rule once."
)
.as_str(),
));
}
rules.push(rule);
}
return Ok(rules);
}
fn markdown_autofix_options(
value: &JsoncValue,
path: &str,
) -> Result<MarkdownAutofixOptions, ConfigError> {
let Some(entries) = value.entries() else {
return Err(wrong_kind(value, path, "an options object"));
};
let keys: Vec<String> = member_keys(entries, path)?;
let mut options: MarkdownAutofixOptions = PolicyConfig::defaults().markdown_autofix;
for (index, key) in keys.iter().enumerate() {
let key_path: String = member_path(path, key.as_str());
let member: &JsoncValue = &entries[index].value;
if key == "rules" {
options.rules = markdown_rules(member, key_path.as_str())?;
} else if key == "exclude" {
options.exclude = strings(member, key_path.as_str())?;
} else if key == "command" {
return Err(ConfigError::new(
format!(
"Configuration key {key_path} is retired: cli-git runs its own coordinated \
Markdown linter and configuration cannot select a command. Remove the key."
)
.as_str(),
));
} else {
return Err(ConfigError::new(
format!("Unknown configuration key: {key_path}. Accepted options: rules, exclude.")
.as_str(),
));
}
}
return Ok(options);
}
fn apply_options(
config: &mut PolicyConfig,
id: PolicyId,
value: &JsoncValue,
path: &str,
) -> Result<(), ConfigError> {
if id == PolicyId::ForbiddenStrings {
config.forbidden_strings = forbidden_strings_options(value, path)?;
} else {
config.markdown_autofix = markdown_autofix_options(value, path)?;
}
return Ok(());
}
fn apply_setting(
config: &mut PolicyConfig,
descriptor: &PolicyDescriptor,
value: &JsoncValue,
) -> Result<(), ConfigError> {
let path: String = member_path("policies", descriptor.name);
let chosen: Severity;
if let Some(elements) = value.elements() {
if !descriptor.accepts_options {
return Err(ConfigError::new(
format!(
"Configuration key {path} does not accept options; write the severity \
alone, for example \"error\"."
)
.as_str(),
));
}
if elements.len() != 2 {
return Err(ConfigError::new(
format!(
"Configuration key {path} must be a severity or exactly [severity, options]; \
found an array of {} items.",
elements.len()
)
.as_str(),
));
}
chosen = severity(&elements[0], format!("{path}[0]").as_str())?;
apply_options(
config,
descriptor.id,
&elements[1],
format!("{path}[1]").as_str(),
)?;
} else if value.text_units().is_some() {
chosen = severity(value, path.as_str())?;
} else if descriptor.accepts_options && value.entries().is_some() {
chosen = descriptor.default_severity;
apply_options(config, descriptor.id, value, path.as_str())?;
} else {
let expected: &str = if descriptor.accepts_options {
"a severity string, [severity, options] or an options object"
} else {
"a severity string"
};
return Err(wrong_kind(value, path.as_str(), expected));
}
for setting in &mut config.settings {
if setting.id == descriptor.id {
setting.severity = chosen;
setting.explicit = true;
}
}
return Ok(());
}
pub(crate) fn parse_policies(value: &JsoncValue) -> Result<PolicyConfig, ConfigError> {
let Some(entries) = value.entries() else {
return Err(wrong_kind(
value,
"policies",
"an object mapping policy IDs to settings",
));
};
let keys: Vec<String> = member_keys(entries, "policies")?;
let mut config: PolicyConfig = PolicyConfig::defaults();
for (index, key) in keys.iter().enumerate() {
let member: &JsoncValue = &entries[index].value;
let Some(descriptor) = policy_by_name(key.as_str()) else {
return Err(ConfigError::new(
format!(
"Unknown policy ID: {key}. Shipped policies: {}.",
known_policy_ids()
)
.as_str(),
));
};
apply_setting(&mut config, descriptor, member)?;
}
return Ok(config);
}