Skip to main content

reformat_core/
config.rs

1//! Configuration file support for reformat presets
2//!
3//! Presets are named transformation pipelines defined in `reformat.json`.
4//! Each preset specifies an ordered list of steps and per-step settings.
5
6use std::collections::HashMap;
7
8use serde::Deserialize;
9
10use crate::case::CaseFormat;
11use crate::rename::{CaseTransform, SpaceReplace};
12
13/// Root configuration: a map of preset names to preset definitions.
14pub type ReformatConfig = HashMap<String, Preset>;
15
16/// A named preset defining an ordered list of transformation steps.
17#[derive(Debug, Clone, Deserialize)]
18pub struct Preset {
19    /// Ordered list of step names to execute.
20    /// Valid values: "rename", "emojis", "clean", "convert", "group"
21    pub steps: Vec<String>,
22    #[serde(default)]
23    pub rename: Option<RenameConfig>,
24    #[serde(default)]
25    pub emojis: Option<EmojiConfig>,
26    #[serde(default)]
27    pub clean: Option<CleanConfig>,
28    #[serde(default)]
29    pub convert: Option<ConvertConfig>,
30    #[serde(default)]
31    pub group: Option<GroupConfig>,
32    #[serde(default)]
33    pub endings: Option<EndingsConfig>,
34    #[serde(default)]
35    pub indent: Option<IndentConfig>,
36    #[serde(default)]
37    pub replace: Option<ReplaceConfig>,
38    #[serde(default)]
39    pub header: Option<HeaderConfig>,
40}
41
42/// Valid step names for presets.
43pub const VALID_STEPS: &[&str] = &[
44    "rename", "emojis", "clean", "convert", "group", "endings", "indent", "replace", "header",
45];
46
47/// Validate that all steps in a preset are recognized.
48pub fn validate_steps(preset_name: &str, steps: &[String]) -> crate::Result<()> {
49    for step in steps {
50        if !VALID_STEPS.contains(&step.as_str()) {
51            anyhow::bail!(
52                "preset '{}': unknown step '{}'. Valid steps: {}",
53                preset_name,
54                step,
55                VALID_STEPS.join(", ")
56            );
57        }
58    }
59    Ok(())
60}
61
62/// Configuration for the rename step.
63#[derive(Debug, Clone, Default, Deserialize)]
64pub struct RenameConfig {
65    pub case_transform: Option<String>,
66    pub space_replace: Option<String>,
67    pub recursive: Option<bool>,
68    pub include_symlinks: Option<bool>,
69}
70
71impl RenameConfig {
72    pub fn parse_case_transform(&self) -> Option<CaseTransform> {
73        self.case_transform.as_deref().map(|s| match s {
74            "lowercase" => CaseTransform::Lowercase,
75            "uppercase" => CaseTransform::Uppercase,
76            "capitalize" => CaseTransform::Capitalize,
77            _ => CaseTransform::None,
78        })
79    }
80
81    pub fn parse_space_replace(&self) -> Option<SpaceReplace> {
82        self.space_replace.as_deref().map(|s| match s {
83            "underscore" => SpaceReplace::Underscore,
84            "hyphen" => SpaceReplace::Hyphen,
85            _ => SpaceReplace::None,
86        })
87    }
88}
89
90/// Configuration for the emojis step.
91#[derive(Debug, Clone, Default, Deserialize)]
92pub struct EmojiConfig {
93    pub replace_task_emojis: Option<bool>,
94    pub remove_other_emojis: Option<bool>,
95    pub file_extensions: Option<Vec<String>>,
96    pub recursive: Option<bool>,
97}
98
99/// Configuration for the clean step.
100#[derive(Debug, Clone, Default, Deserialize)]
101pub struct CleanConfig {
102    pub remove_trailing: Option<bool>,
103    pub file_extensions: Option<Vec<String>>,
104    pub recursive: Option<bool>,
105}
106
107/// Configuration for the convert step.
108#[derive(Debug, Clone, Default, Deserialize)]
109pub struct ConvertConfig {
110    pub from_format: Option<String>,
111    pub to_format: Option<String>,
112    pub file_extensions: Option<Vec<String>>,
113    pub recursive: Option<bool>,
114    pub prefix: Option<String>,
115    pub suffix: Option<String>,
116    pub glob: Option<String>,
117    pub word_filter: Option<String>,
118}
119
120impl ConvertConfig {
121    pub fn parse_from_format(&self) -> Option<CaseFormat> {
122        self.from_format.as_deref().and_then(parse_case_format)
123    }
124
125    pub fn parse_to_format(&self) -> Option<CaseFormat> {
126        self.to_format.as_deref().and_then(parse_case_format)
127    }
128}
129
130/// Configuration for the group step.
131#[derive(Debug, Clone, Default, Deserialize)]
132pub struct GroupConfig {
133    pub separator: Option<String>,
134    pub min_count: Option<usize>,
135    pub strip_prefix: Option<bool>,
136    pub from_suffix: Option<bool>,
137    pub recursive: Option<bool>,
138}
139
140/// Configuration for the endings step.
141#[derive(Debug, Clone, Default, Deserialize)]
142pub struct EndingsConfig {
143    pub style: Option<String>,
144    pub file_extensions: Option<Vec<String>>,
145    pub recursive: Option<bool>,
146}
147
148/// Configuration for the indent step.
149#[derive(Debug, Clone, Default, Deserialize)]
150pub struct IndentConfig {
151    pub style: Option<String>,
152    pub width: Option<usize>,
153    pub file_extensions: Option<Vec<String>>,
154    pub recursive: Option<bool>,
155}
156
157/// A single replace pattern in config.
158#[derive(Debug, Clone, Deserialize)]
159pub struct ReplacePatternEntry {
160    pub find: String,
161    pub replace: String,
162}
163
164/// Configuration for the replace step.
165#[derive(Debug, Clone, Default, Deserialize)]
166pub struct ReplaceConfig {
167    pub patterns: Option<Vec<ReplacePatternEntry>>,
168    pub file_extensions: Option<Vec<String>>,
169    pub recursive: Option<bool>,
170}
171
172/// Configuration for the header step.
173#[derive(Debug, Clone, Default, Deserialize)]
174pub struct HeaderConfig {
175    pub text: Option<String>,
176    pub update_year: Option<bool>,
177    pub file_extensions: Option<Vec<String>>,
178    pub recursive: Option<bool>,
179}
180
181fn parse_case_format(s: &str) -> Option<CaseFormat> {
182    match s {
183        "camel" | "camelCase" => Some(CaseFormat::CamelCase),
184        "pascal" | "PascalCase" => Some(CaseFormat::PascalCase),
185        "snake" | "snake_case" => Some(CaseFormat::SnakeCase),
186        "screaming_snake" | "SCREAMING_SNAKE_CASE" => Some(CaseFormat::ScreamingSnakeCase),
187        "kebab" | "kebab-case" => Some(CaseFormat::KebabCase),
188        "screaming_kebab" | "SCREAMING-KEBAB-CASE" => Some(CaseFormat::ScreamingKebabCase),
189        _ => None,
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn test_deserialize_full_config() {
199        let json = r#"{
200            "code": {
201                "steps": ["rename", "emojis", "clean"],
202                "rename": {
203                    "case_transform": "lowercase",
204                    "space_replace": "hyphen",
205                    "recursive": true,
206                    "include_symlinks": false
207                },
208                "emojis": {
209                    "replace_task_emojis": true,
210                    "remove_other_emojis": false,
211                    "file_extensions": [".md", ".txt"]
212                },
213                "clean": {
214                    "remove_trailing": true,
215                    "file_extensions": [".rs", ".py"]
216                }
217            },
218            "templates": {
219                "steps": ["group", "clean"],
220                "group": {
221                    "separator": "_",
222                    "min_count": 3,
223                    "strip_prefix": true,
224                    "from_suffix": false
225                }
226            }
227        }"#;
228
229        let config: ReformatConfig = serde_json::from_str(json).unwrap();
230        assert_eq!(config.len(), 2);
231
232        let code = &config["code"];
233        assert_eq!(code.steps, vec!["rename", "emojis", "clean"]);
234
235        let rename = code.rename.as_ref().unwrap();
236        assert_eq!(rename.case_transform.as_deref(), Some("lowercase"));
237        assert_eq!(
238            rename.parse_case_transform(),
239            Some(CaseTransform::Lowercase)
240        );
241        assert_eq!(rename.parse_space_replace(), Some(SpaceReplace::Hyphen));
242
243        let emojis = code.emojis.as_ref().unwrap();
244        assert_eq!(emojis.replace_task_emojis, Some(true));
245        assert_eq!(emojis.remove_other_emojis, Some(false));
246        assert_eq!(
247            emojis.file_extensions.as_ref().unwrap(),
248            &vec![".md".to_string(), ".txt".to_string()]
249        );
250
251        let templates = &config["templates"];
252        assert_eq!(templates.steps, vec!["group", "clean"]);
253        let group = templates.group.as_ref().unwrap();
254        assert_eq!(group.min_count, Some(3));
255        assert_eq!(group.strip_prefix, Some(true));
256    }
257
258    #[test]
259    fn test_deserialize_minimal_config() {
260        let json = r#"{
261            "quick": {
262                "steps": ["clean"]
263            }
264        }"#;
265
266        let config: ReformatConfig = serde_json::from_str(json).unwrap();
267        let quick = &config["quick"];
268        assert_eq!(quick.steps, vec!["clean"]);
269        assert!(quick.rename.is_none());
270        assert!(quick.emojis.is_none());
271        assert!(quick.clean.is_none());
272        assert!(quick.convert.is_none());
273        assert!(quick.group.is_none());
274    }
275
276    #[test]
277    fn test_deserialize_convert_config() {
278        let json = r#"{
279            "case-fix": {
280                "steps": ["convert"],
281                "convert": {
282                    "from_format": "camel",
283                    "to_format": "snake",
284                    "file_extensions": [".py"],
285                    "recursive": true,
286                    "prefix": "pre_",
287                    "suffix": "_suf"
288                }
289            }
290        }"#;
291
292        let config: ReformatConfig = serde_json::from_str(json).unwrap();
293        let preset = &config["case-fix"];
294        let convert = preset.convert.as_ref().unwrap();
295        assert_eq!(convert.parse_from_format(), Some(CaseFormat::CamelCase));
296        assert_eq!(convert.parse_to_format(), Some(CaseFormat::SnakeCase));
297        assert_eq!(convert.prefix.as_deref(), Some("pre_"));
298        assert_eq!(convert.suffix.as_deref(), Some("_suf"));
299    }
300
301    #[test]
302    fn test_validate_steps_valid() {
303        let steps = vec![
304            "rename".to_string(),
305            "emojis".to_string(),
306            "clean".to_string(),
307        ];
308        assert!(validate_steps("test", &steps).is_ok());
309    }
310
311    #[test]
312    fn test_validate_steps_invalid() {
313        let steps = vec!["rename".to_string(), "bogus".to_string()];
314        let err = validate_steps("test", &steps).unwrap_err();
315        assert!(err.to_string().contains("unknown step 'bogus'"));
316    }
317
318    #[test]
319    fn test_parse_case_format() {
320        assert_eq!(parse_case_format("camel"), Some(CaseFormat::CamelCase));
321        assert_eq!(parse_case_format("camelCase"), Some(CaseFormat::CamelCase));
322        assert_eq!(parse_case_format("pascal"), Some(CaseFormat::PascalCase));
323        assert_eq!(parse_case_format("snake"), Some(CaseFormat::SnakeCase));
324        assert_eq!(
325            parse_case_format("screaming_snake"),
326            Some(CaseFormat::ScreamingSnakeCase)
327        );
328        assert_eq!(parse_case_format("kebab"), Some(CaseFormat::KebabCase));
329        assert_eq!(
330            parse_case_format("screaming_kebab"),
331            Some(CaseFormat::ScreamingKebabCase)
332        );
333        assert_eq!(parse_case_format("unknown"), None);
334    }
335
336    #[test]
337    fn test_unknown_fields_ignored() {
338        let json = r#"{
339            "test": {
340                "steps": ["clean"],
341                "clean": {
342                    "remove_trailing": true,
343                    "some_future_field": 42
344                }
345            }
346        }"#;
347
348        // serde default behavior: unknown fields cause an error unless denied
349        // We want to test current behavior
350        let result: Result<ReformatConfig, _> = serde_json::from_str(json);
351        // By default serde_json ignores unknown fields
352        assert!(result.is_ok());
353    }
354}