use std::collections::HashMap;
use serde::Deserialize;
use crate::case::CaseFormat;
use crate::rename::{CaseTransform, SpaceReplace};
pub type ReformatConfig = HashMap<String, Preset>;
#[derive(Debug, Clone, Deserialize)]
pub struct Preset {
pub steps: Vec<String>,
#[serde(default)]
pub rename: Option<RenameConfig>,
#[serde(default)]
pub emojis: Option<EmojiConfig>,
#[serde(default)]
pub clean: Option<CleanConfig>,
#[serde(default)]
pub convert: Option<ConvertConfig>,
#[serde(default)]
pub group: Option<GroupConfig>,
#[serde(default)]
pub endings: Option<EndingsConfig>,
#[serde(default)]
pub indent: Option<IndentConfig>,
#[serde(default)]
pub replace: Option<ReplaceConfig>,
#[serde(default)]
pub header: Option<HeaderConfig>,
}
pub const VALID_STEPS: &[&str] = &[
"rename", "emojis", "clean", "convert", "group", "endings", "indent", "replace", "header",
];
pub fn validate_steps(preset_name: &str, steps: &[String]) -> crate::Result<()> {
for step in steps {
if !VALID_STEPS.contains(&step.as_str()) {
anyhow::bail!(
"preset '{}': unknown step '{}'. Valid steps: {}",
preset_name,
step,
VALID_STEPS.join(", ")
);
}
}
Ok(())
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct RenameConfig {
pub case_transform: Option<String>,
pub space_replace: Option<String>,
pub recursive: Option<bool>,
pub include_symlinks: Option<bool>,
}
impl RenameConfig {
pub fn parse_case_transform(&self) -> Option<CaseTransform> {
self.case_transform.as_deref().map(|s| match s {
"lowercase" => CaseTransform::Lowercase,
"uppercase" => CaseTransform::Uppercase,
"capitalize" => CaseTransform::Capitalize,
_ => CaseTransform::None,
})
}
pub fn parse_space_replace(&self) -> Option<SpaceReplace> {
self.space_replace.as_deref().map(|s| match s {
"underscore" => SpaceReplace::Underscore,
"hyphen" => SpaceReplace::Hyphen,
_ => SpaceReplace::None,
})
}
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct EmojiConfig {
pub replace_task_emojis: Option<bool>,
pub remove_other_emojis: Option<bool>,
pub file_extensions: Option<Vec<String>>,
pub recursive: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct CleanConfig {
pub remove_trailing: Option<bool>,
pub file_extensions: Option<Vec<String>>,
pub recursive: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ConvertConfig {
pub from_format: Option<String>,
pub to_format: Option<String>,
pub file_extensions: Option<Vec<String>>,
pub recursive: Option<bool>,
pub prefix: Option<String>,
pub suffix: Option<String>,
pub glob: Option<String>,
pub word_filter: Option<String>,
}
impl ConvertConfig {
pub fn parse_from_format(&self) -> Option<CaseFormat> {
self.from_format.as_deref().and_then(parse_case_format)
}
pub fn parse_to_format(&self) -> Option<CaseFormat> {
self.to_format.as_deref().and_then(parse_case_format)
}
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct GroupConfig {
pub separator: Option<String>,
pub min_count: Option<usize>,
pub strip_prefix: Option<bool>,
pub from_suffix: Option<bool>,
pub recursive: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct EndingsConfig {
pub style: Option<String>,
pub file_extensions: Option<Vec<String>>,
pub recursive: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct IndentConfig {
pub style: Option<String>,
pub width: Option<usize>,
pub file_extensions: Option<Vec<String>>,
pub recursive: Option<bool>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ReplacePatternEntry {
pub find: String,
pub replace: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ReplaceConfig {
pub patterns: Option<Vec<ReplacePatternEntry>>,
pub file_extensions: Option<Vec<String>>,
pub recursive: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct HeaderConfig {
pub text: Option<String>,
pub update_year: Option<bool>,
pub file_extensions: Option<Vec<String>>,
pub recursive: Option<bool>,
}
fn parse_case_format(s: &str) -> Option<CaseFormat> {
match s {
"camel" | "camelCase" => Some(CaseFormat::CamelCase),
"pascal" | "PascalCase" => Some(CaseFormat::PascalCase),
"snake" | "snake_case" => Some(CaseFormat::SnakeCase),
"screaming_snake" | "SCREAMING_SNAKE_CASE" => Some(CaseFormat::ScreamingSnakeCase),
"kebab" | "kebab-case" => Some(CaseFormat::KebabCase),
"screaming_kebab" | "SCREAMING-KEBAB-CASE" => Some(CaseFormat::ScreamingKebabCase),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_deserialize_full_config() {
let json = r#"{
"code": {
"steps": ["rename", "emojis", "clean"],
"rename": {
"case_transform": "lowercase",
"space_replace": "hyphen",
"recursive": true,
"include_symlinks": false
},
"emojis": {
"replace_task_emojis": true,
"remove_other_emojis": false,
"file_extensions": [".md", ".txt"]
},
"clean": {
"remove_trailing": true,
"file_extensions": [".rs", ".py"]
}
},
"templates": {
"steps": ["group", "clean"],
"group": {
"separator": "_",
"min_count": 3,
"strip_prefix": true,
"from_suffix": false
}
}
}"#;
let config: ReformatConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.len(), 2);
let code = &config["code"];
assert_eq!(code.steps, vec!["rename", "emojis", "clean"]);
let rename = code.rename.as_ref().unwrap();
assert_eq!(rename.case_transform.as_deref(), Some("lowercase"));
assert_eq!(
rename.parse_case_transform(),
Some(CaseTransform::Lowercase)
);
assert_eq!(rename.parse_space_replace(), Some(SpaceReplace::Hyphen));
let emojis = code.emojis.as_ref().unwrap();
assert_eq!(emojis.replace_task_emojis, Some(true));
assert_eq!(emojis.remove_other_emojis, Some(false));
assert_eq!(
emojis.file_extensions.as_ref().unwrap(),
&vec![".md".to_string(), ".txt".to_string()]
);
let templates = &config["templates"];
assert_eq!(templates.steps, vec!["group", "clean"]);
let group = templates.group.as_ref().unwrap();
assert_eq!(group.min_count, Some(3));
assert_eq!(group.strip_prefix, Some(true));
}
#[test]
fn test_deserialize_minimal_config() {
let json = r#"{
"quick": {
"steps": ["clean"]
}
}"#;
let config: ReformatConfig = serde_json::from_str(json).unwrap();
let quick = &config["quick"];
assert_eq!(quick.steps, vec!["clean"]);
assert!(quick.rename.is_none());
assert!(quick.emojis.is_none());
assert!(quick.clean.is_none());
assert!(quick.convert.is_none());
assert!(quick.group.is_none());
}
#[test]
fn test_deserialize_convert_config() {
let json = r#"{
"case-fix": {
"steps": ["convert"],
"convert": {
"from_format": "camel",
"to_format": "snake",
"file_extensions": [".py"],
"recursive": true,
"prefix": "pre_",
"suffix": "_suf"
}
}
}"#;
let config: ReformatConfig = serde_json::from_str(json).unwrap();
let preset = &config["case-fix"];
let convert = preset.convert.as_ref().unwrap();
assert_eq!(convert.parse_from_format(), Some(CaseFormat::CamelCase));
assert_eq!(convert.parse_to_format(), Some(CaseFormat::SnakeCase));
assert_eq!(convert.prefix.as_deref(), Some("pre_"));
assert_eq!(convert.suffix.as_deref(), Some("_suf"));
}
#[test]
fn test_validate_steps_valid() {
let steps = vec![
"rename".to_string(),
"emojis".to_string(),
"clean".to_string(),
];
assert!(validate_steps("test", &steps).is_ok());
}
#[test]
fn test_validate_steps_invalid() {
let steps = vec!["rename".to_string(), "bogus".to_string()];
let err = validate_steps("test", &steps).unwrap_err();
assert!(err.to_string().contains("unknown step 'bogus'"));
}
#[test]
fn test_parse_case_format() {
assert_eq!(parse_case_format("camel"), Some(CaseFormat::CamelCase));
assert_eq!(parse_case_format("camelCase"), Some(CaseFormat::CamelCase));
assert_eq!(parse_case_format("pascal"), Some(CaseFormat::PascalCase));
assert_eq!(parse_case_format("snake"), Some(CaseFormat::SnakeCase));
assert_eq!(
parse_case_format("screaming_snake"),
Some(CaseFormat::ScreamingSnakeCase)
);
assert_eq!(parse_case_format("kebab"), Some(CaseFormat::KebabCase));
assert_eq!(
parse_case_format("screaming_kebab"),
Some(CaseFormat::ScreamingKebabCase)
);
assert_eq!(parse_case_format("unknown"), None);
}
#[test]
fn test_unknown_fields_ignored() {
let json = r#"{
"test": {
"steps": ["clean"],
"clean": {
"remove_trailing": true,
"some_future_field": 42
}
}
}"#;
let result: Result<ReformatConfig, _> = serde_json::from_str(json);
assert!(result.is_ok());
}
}