use rayon::prelude::*;
use serde::Deserialize;
use std::fs;
use std::path::{Path, PathBuf};
use crate::{Document, QuoteStyle, YerbaError};
#[derive(Debug, Deserialize)]
pub struct Yerbafile {
#[serde(default)]
pub rules: Vec<Rule>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Rule {
pub files: String,
#[serde(default)]
pub path: Option<String>,
pub pipeline: Vec<PipelineStep>,
}
#[derive(Debug, Clone)]
pub enum PipelineStep {
SortKeys(SortKeysConfig),
QuoteStyle(QuoteStyleConfig),
Set(SetConfig),
Insert(InsertConfig),
Delete(DeleteConfig),
Rename(RenameConfig),
Remove(RemoveConfig),
BlankLines(BlankLinesConfig),
Sort(SortConfig),
}
#[derive(Debug, Clone, Deserialize)]
pub struct SortConfig {
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub by: Option<String>,
#[serde(default)]
pub case_sensitive: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct BlankLinesConfig {
#[serde(default)]
pub path: Option<String>,
pub count: usize,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RenameConfig {
pub from: String,
pub to: String,
#[serde(default)]
pub condition: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct RemoveConfig {
pub path: String,
pub value: String,
#[serde(default)]
pub condition: Option<String>,
}
impl<'de> Deserialize<'de> for PipelineStep {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let mapping = serde_yaml::Mapping::deserialize(deserializer)?;
if let Some(value) = mapping.get(serde_yaml::Value::String("sort_keys".to_string())) {
let config: SortKeysConfig = serde_yaml::from_value(value.clone()).map_err(serde::de::Error::custom)?;
return Ok(PipelineStep::SortKeys(config));
}
if let Some(value) = mapping.get(serde_yaml::Value::String("quote_style".to_string())) {
let config: QuoteStyleConfig = serde_yaml::from_value(value.clone()).map_err(serde::de::Error::custom)?;
return Ok(PipelineStep::QuoteStyle(config));
}
if let Some(value) = mapping.get(serde_yaml::Value::String("set".to_string())) {
let config: SetConfig = serde_yaml::from_value(value.clone()).map_err(serde::de::Error::custom)?;
return Ok(PipelineStep::Set(config));
}
if let Some(value) = mapping.get(serde_yaml::Value::String("insert".to_string())) {
let config: InsertConfig = serde_yaml::from_value(value.clone()).map_err(serde::de::Error::custom)?;
return Ok(PipelineStep::Insert(config));
}
if let Some(value) = mapping.get(serde_yaml::Value::String("delete".to_string())) {
let config: DeleteConfig = serde_yaml::from_value(value.clone()).map_err(serde::de::Error::custom)?;
return Ok(PipelineStep::Delete(config));
}
if let Some(value) = mapping.get(serde_yaml::Value::String("rename".to_string())) {
let config: RenameConfig = serde_yaml::from_value(value.clone()).map_err(serde::de::Error::custom)?;
return Ok(PipelineStep::Rename(config));
}
if let Some(value) = mapping.get(serde_yaml::Value::String("remove".to_string())) {
let config: RemoveConfig = serde_yaml::from_value(value.clone()).map_err(serde::de::Error::custom)?;
return Ok(PipelineStep::Remove(config));
}
if let Some(value) = mapping.get(serde_yaml::Value::String("blank_lines".to_string())) {
let config: BlankLinesConfig = serde_yaml::from_value(value.clone()).map_err(serde::de::Error::custom)?;
return Ok(PipelineStep::BlankLines(config));
}
if let Some(value) = mapping.get(serde_yaml::Value::String("sort".to_string())) {
let config: SortConfig = serde_yaml::from_value(value.clone()).map_err(serde::de::Error::custom)?;
return Ok(PipelineStep::Sort(config));
}
Err(serde::de::Error::custom(
"unknown pipeline step: expected sort_keys, quote_style, set, insert, delete, rename, remove, blank_lines, or sort",
))
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct SortKeysConfig {
#[serde(default)]
pub path: Option<String>,
pub order: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct QuoteStyleConfig {
#[serde(default = "default_key_style")]
pub key_style: String,
pub value_style: String,
#[serde(default)]
pub path: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct SetConfig {
pub path: String,
pub value: String,
#[serde(default)]
pub condition: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct InsertConfig {
pub path: String,
pub value: String,
#[serde(default)]
pub condition: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DeleteConfig {
pub path: String,
#[serde(default)]
pub condition: Option<String>,
}
fn default_key_style() -> String {
"plain".to_string()
}
#[derive(Debug)]
pub struct RuleResult {
pub file: String,
pub changed: bool,
pub error: Option<String>,
}
impl Yerbafile {
pub fn load(path: impl AsRef<Path>) -> Result<Self, YerbaError> {
let content = fs::read_to_string(path.as_ref())?;
let yerbafile: Yerbafile =
serde_yaml::from_str(&content).map_err(|error| YerbaError::ParseError(format!("{}", error)))?;
Ok(yerbafile)
}
pub fn find() -> Option<PathBuf> {
let candidates = ["Yerbafile", "Yerbafile.yml", "Yerbafile.yaml", ".yerbafile"];
let mut directory = std::env::current_dir().ok()?;
loop {
for candidate in &candidates {
let path = directory.join(candidate);
if path.exists() {
return Some(path);
}
}
if !directory.pop() {
return None;
}
}
}
pub fn sort_order_for(&self, file_path: &str, dot_path: &str) -> Option<Vec<String>> {
for rule in &self.rules {
let sort_keys_config = rule.pipeline.iter().find_map(|step| match step {
PipelineStep::SortKeys(config) => Some(config),
_ => None,
});
if let Some(config) = sort_keys_config {
let full_path = resolve_step_path(rule.path.as_deref(), config.path.as_deref());
let normalized = full_path.trim_end_matches("[]").trim_end_matches('.');
if normalized != dot_path && !normalized.is_empty() && !dot_path.is_empty() {
continue;
}
if let Ok(pattern) = glob::Pattern::new(&rule.files) {
if pattern.matches(file_path) || pattern.matches_path(Path::new(file_path)) {
return Some(config.order.clone());
}
}
}
}
None
}
pub fn apply(&self, write: bool) -> Vec<RuleResult> {
let mut results = Vec::new();
for rule in &self.rules {
let files = match glob::glob(&rule.files) {
Ok(paths) => paths.filter_map(|entry| entry.ok()).collect::<Vec<_>>(),
Err(error) => {
results.push(RuleResult {
file: rule.files.clone(),
changed: false,
error: Some(format!("invalid glob: {}", error)),
});
continue;
}
};
let file_strings: Vec<String> = files.iter().map(|path| path.to_string_lossy().to_string()).collect();
let mut has_validation_error = false;
for step in &rule.pipeline {
if let PipelineStep::SortKeys(config) = step {
let full_path = resolve_step_path(rule.path.as_deref(), config.path.as_deref());
let key_order: Vec<&str> = config.order.iter().map(|key| key.as_str()).collect();
let validation_results: Vec<RuleResult> = file_strings
.par_iter()
.filter_map(|file| {
let document = match Document::parse_file(file) {
Ok(document) => document,
Err(error) => {
return Some(RuleResult {
file: file.clone(),
changed: false,
error: Some(format!("{}", error)),
});
}
};
if let Err(error) = document.validate_sort_keys(&full_path, &key_order) {
Some(RuleResult {
file: file.clone(),
changed: false,
error: Some(format!("{}", error)),
})
} else {
None
}
})
.collect();
if !validation_results.is_empty() {
has_validation_error = true;
results.extend(validation_results);
}
}
}
if has_validation_error {
continue;
}
let file_results: Vec<RuleResult> = file_strings
.par_iter()
.map(|file| self.apply_pipeline_to_file(rule, file, write))
.collect();
results.extend(file_results);
}
results
}
fn apply_pipeline_to_file(&self, rule: &Rule, file: &str, write: bool) -> RuleResult {
let mut document = match Document::parse_file(file) {
Ok(document) => document,
Err(error) => {
return RuleResult {
file: file.to_string(),
changed: false,
error: Some(format!("{}", error)),
}
}
};
let original = document.to_string();
let base_path = rule.path.as_deref();
for step in &rule.pipeline {
if let Err(error) = execute_step(&mut document, step, base_path) {
return RuleResult {
file: file.to_string(),
changed: false,
error: Some(format!("{}", error)),
};
}
}
let new_content = document.to_string();
let changed = new_content != original;
if changed && write {
if let Err(error) = fs::write(file, &new_content) {
return RuleResult {
file: file.to_string(),
changed,
error: Some(format!("{}", error)),
};
}
}
RuleResult {
file: file.to_string(),
changed,
error: None,
}
}
}
fn execute_step(document: &mut Document, step: &PipelineStep, base_path: Option<&str>) -> Result<(), YerbaError> {
match step {
PipelineStep::QuoteStyle(config) => {
let dot_path = config.path.as_deref();
let key_style = config.key_style.parse::<QuoteStyle>().map_err(YerbaError::ParseError)?;
let value_style = config
.value_style
.parse::<QuoteStyle>()
.map_err(YerbaError::ParseError)?;
document.enforce_key_style(&key_style, dot_path)?;
document.enforce_quotes_at(&value_style, dot_path)?;
Ok(())
}
PipelineStep::SortKeys(config) => {
let full_path = resolve_step_path(base_path, config.path.as_deref());
let key_order: Vec<&str> = config.order.iter().map(|key| key.as_str()).collect();
document.sort_keys(&full_path, &key_order)
}
PipelineStep::Set(config) => {
let full_path = resolve_step_path(base_path, Some(&config.path));
if let Some(condition) = &config.condition {
let parent_path = full_path.rsplit_once('.').map(|(parent, _)| parent).unwrap_or("");
if !document.evaluate_condition(parent_path, condition) {
return Ok(());
}
}
document.set(&full_path, &config.value)
}
PipelineStep::Insert(config) => {
let full_path = resolve_step_path(base_path, Some(&config.path));
if let Some(condition) = &config.condition {
let parent_path = full_path.rsplit_once('.').map(|(parent, _)| parent).unwrap_or("");
if !document.evaluate_condition(parent_path, condition) {
return Ok(());
}
}
document.insert_into(&full_path, &config.value, crate::InsertPosition::Last)
}
PipelineStep::Delete(config) => {
let full_path = resolve_step_path(base_path, Some(&config.path));
if let Some(condition) = &config.condition {
let parent_path = full_path.rsplit_once('.').map(|(parent, _)| parent).unwrap_or("");
if !document.evaluate_condition(parent_path, condition) {
return Ok(());
}
}
document.delete(&full_path)
}
PipelineStep::Rename(config) => {
let full_path = resolve_step_path(base_path, Some(&config.from));
if let Some(condition) = &config.condition {
let parent_path = full_path.rsplit_once('.').map(|(parent, _)| parent).unwrap_or("");
if !document.evaluate_condition(parent_path, condition) {
return Ok(());
}
}
document.rename(&full_path, &config.to)
}
PipelineStep::Remove(config) => {
let full_path = resolve_step_path(base_path, Some(&config.path));
if let Some(condition) = &config.condition {
let parent_path = full_path.rsplit_once('.').map(|(parent, _)| parent).unwrap_or("");
if !document.evaluate_condition(parent_path, condition) {
return Ok(());
}
}
document.remove(&full_path, &config.value)
}
PipelineStep::BlankLines(config) => {
let full_path = resolve_step_path(base_path, config.path.as_deref());
document.enforce_blank_lines(&full_path, config.count)
}
PipelineStep::Sort(config) => {
let full_path = resolve_step_path(base_path, config.path.as_deref());
let sort_fields = config
.by
.as_deref()
.map(crate::SortField::parse_list)
.unwrap_or_default();
document.sort_items(&full_path, &sort_fields, config.case_sensitive)
}
}
}
fn resolve_step_path(base_path: Option<&str>, step_path: Option<&str>) -> String {
let base = base_path.unwrap_or("");
let step = step_path.unwrap_or("");
match (base.is_empty(), step.is_empty()) {
(true, true) => String::new(),
(true, false) => step.to_string(),
(false, true) => base.to_string(),
(false, false) => format!("{}.{}", base, step),
}
}