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}
33
34/// Valid step names for presets.
35pub const VALID_STEPS: &[&str] = &["rename", "emojis", "clean", "convert", "group"];
36
37/// Validate that all steps in a preset are recognized.
38pub fn validate_steps(preset_name: &str, steps: &[String]) -> crate::Result<()> {
39    for step in steps {
40        if !VALID_STEPS.contains(&step.as_str()) {
41            anyhow::bail!(
42                "preset '{}': unknown step '{}'. Valid steps: {}",
43                preset_name,
44                step,
45                VALID_STEPS.join(", ")
46            );
47        }
48    }
49    Ok(())
50}
51
52/// Configuration for the rename step.
53#[derive(Debug, Clone, Default, Deserialize)]
54pub struct RenameConfig {
55    pub case_transform: Option<String>,
56    pub space_replace: Option<String>,
57    pub recursive: Option<bool>,
58    pub include_symlinks: Option<bool>,
59}
60
61impl RenameConfig {
62    pub fn parse_case_transform(&self) -> Option<CaseTransform> {
63        self.case_transform.as_deref().map(|s| match s {
64            "lowercase" => CaseTransform::Lowercase,
65            "uppercase" => CaseTransform::Uppercase,
66            "capitalize" => CaseTransform::Capitalize,
67            _ => CaseTransform::None,
68        })
69    }
70
71    pub fn parse_space_replace(&self) -> Option<SpaceReplace> {
72        self.space_replace.as_deref().map(|s| match s {
73            "underscore" => SpaceReplace::Underscore,
74            "hyphen" => SpaceReplace::Hyphen,
75            _ => SpaceReplace::None,
76        })
77    }
78}
79
80/// Configuration for the emojis step.
81#[derive(Debug, Clone, Default, Deserialize)]
82pub struct EmojiConfig {
83    pub replace_task_emojis: Option<bool>,
84    pub remove_other_emojis: Option<bool>,
85    pub file_extensions: Option<Vec<String>>,
86    pub recursive: Option<bool>,
87}
88
89/// Configuration for the clean step.
90#[derive(Debug, Clone, Default, Deserialize)]
91pub struct CleanConfig {
92    pub remove_trailing: Option<bool>,
93    pub file_extensions: Option<Vec<String>>,
94    pub recursive: Option<bool>,
95}
96
97/// Configuration for the convert step.
98#[derive(Debug, Clone, Default, Deserialize)]
99pub struct ConvertConfig {
100    pub from_format: Option<String>,
101    pub to_format: Option<String>,
102    pub file_extensions: Option<Vec<String>>,
103    pub recursive: Option<bool>,
104    pub prefix: Option<String>,
105    pub suffix: Option<String>,
106    pub glob: Option<String>,
107    pub word_filter: Option<String>,
108}
109
110impl ConvertConfig {
111    pub fn parse_from_format(&self) -> Option<CaseFormat> {
112        self.from_format.as_deref().and_then(parse_case_format)
113    }
114
115    pub fn parse_to_format(&self) -> Option<CaseFormat> {
116        self.to_format.as_deref().and_then(parse_case_format)
117    }
118}
119
120/// Configuration for the group step.
121#[derive(Debug, Clone, Default, Deserialize)]
122pub struct GroupConfig {
123    pub separator: Option<String>,
124    pub min_count: Option<usize>,
125    pub strip_prefix: Option<bool>,
126    pub from_suffix: Option<bool>,
127    pub recursive: Option<bool>,
128}
129
130fn parse_case_format(s: &str) -> Option<CaseFormat> {
131    match s {
132        "camel" | "camelCase" => Some(CaseFormat::CamelCase),
133        "pascal" | "PascalCase" => Some(CaseFormat::PascalCase),
134        "snake" | "snake_case" => Some(CaseFormat::SnakeCase),
135        "screaming_snake" | "SCREAMING_SNAKE_CASE" => Some(CaseFormat::ScreamingSnakeCase),
136        "kebab" | "kebab-case" => Some(CaseFormat::KebabCase),
137        "screaming_kebab" | "SCREAMING-KEBAB-CASE" => Some(CaseFormat::ScreamingKebabCase),
138        _ => None,
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn test_deserialize_full_config() {
148        let json = r#"{
149            "code": {
150                "steps": ["rename", "emojis", "clean"],
151                "rename": {
152                    "case_transform": "lowercase",
153                    "space_replace": "hyphen",
154                    "recursive": true,
155                    "include_symlinks": false
156                },
157                "emojis": {
158                    "replace_task_emojis": true,
159                    "remove_other_emojis": false,
160                    "file_extensions": [".md", ".txt"]
161                },
162                "clean": {
163                    "remove_trailing": true,
164                    "file_extensions": [".rs", ".py"]
165                }
166            },
167            "templates": {
168                "steps": ["group", "clean"],
169                "group": {
170                    "separator": "_",
171                    "min_count": 3,
172                    "strip_prefix": true,
173                    "from_suffix": false
174                }
175            }
176        }"#;
177
178        let config: ReformatConfig = serde_json::from_str(json).unwrap();
179        assert_eq!(config.len(), 2);
180
181        let code = &config["code"];
182        assert_eq!(code.steps, vec!["rename", "emojis", "clean"]);
183
184        let rename = code.rename.as_ref().unwrap();
185        assert_eq!(rename.case_transform.as_deref(), Some("lowercase"));
186        assert_eq!(rename.parse_case_transform(), Some(CaseTransform::Lowercase));
187        assert_eq!(rename.parse_space_replace(), Some(SpaceReplace::Hyphen));
188
189        let emojis = code.emojis.as_ref().unwrap();
190        assert_eq!(emojis.replace_task_emojis, Some(true));
191        assert_eq!(emojis.remove_other_emojis, Some(false));
192        assert_eq!(
193            emojis.file_extensions.as_ref().unwrap(),
194            &vec![".md".to_string(), ".txt".to_string()]
195        );
196
197        let templates = &config["templates"];
198        assert_eq!(templates.steps, vec!["group", "clean"]);
199        let group = templates.group.as_ref().unwrap();
200        assert_eq!(group.min_count, Some(3));
201        assert_eq!(group.strip_prefix, Some(true));
202    }
203
204    #[test]
205    fn test_deserialize_minimal_config() {
206        let json = r#"{
207            "quick": {
208                "steps": ["clean"]
209            }
210        }"#;
211
212        let config: ReformatConfig = serde_json::from_str(json).unwrap();
213        let quick = &config["quick"];
214        assert_eq!(quick.steps, vec!["clean"]);
215        assert!(quick.rename.is_none());
216        assert!(quick.emojis.is_none());
217        assert!(quick.clean.is_none());
218        assert!(quick.convert.is_none());
219        assert!(quick.group.is_none());
220    }
221
222    #[test]
223    fn test_deserialize_convert_config() {
224        let json = r#"{
225            "case-fix": {
226                "steps": ["convert"],
227                "convert": {
228                    "from_format": "camel",
229                    "to_format": "snake",
230                    "file_extensions": [".py"],
231                    "recursive": true,
232                    "prefix": "pre_",
233                    "suffix": "_suf"
234                }
235            }
236        }"#;
237
238        let config: ReformatConfig = serde_json::from_str(json).unwrap();
239        let preset = &config["case-fix"];
240        let convert = preset.convert.as_ref().unwrap();
241        assert_eq!(convert.parse_from_format(), Some(CaseFormat::CamelCase));
242        assert_eq!(convert.parse_to_format(), Some(CaseFormat::SnakeCase));
243        assert_eq!(convert.prefix.as_deref(), Some("pre_"));
244        assert_eq!(convert.suffix.as_deref(), Some("_suf"));
245    }
246
247    #[test]
248    fn test_validate_steps_valid() {
249        let steps = vec![
250            "rename".to_string(),
251            "emojis".to_string(),
252            "clean".to_string(),
253        ];
254        assert!(validate_steps("test", &steps).is_ok());
255    }
256
257    #[test]
258    fn test_validate_steps_invalid() {
259        let steps = vec!["rename".to_string(), "bogus".to_string()];
260        let err = validate_steps("test", &steps).unwrap_err();
261        assert!(err.to_string().contains("unknown step 'bogus'"));
262    }
263
264    #[test]
265    fn test_parse_case_format() {
266        assert_eq!(parse_case_format("camel"), Some(CaseFormat::CamelCase));
267        assert_eq!(parse_case_format("camelCase"), Some(CaseFormat::CamelCase));
268        assert_eq!(parse_case_format("pascal"), Some(CaseFormat::PascalCase));
269        assert_eq!(parse_case_format("snake"), Some(CaseFormat::SnakeCase));
270        assert_eq!(
271            parse_case_format("screaming_snake"),
272            Some(CaseFormat::ScreamingSnakeCase)
273        );
274        assert_eq!(parse_case_format("kebab"), Some(CaseFormat::KebabCase));
275        assert_eq!(
276            parse_case_format("screaming_kebab"),
277            Some(CaseFormat::ScreamingKebabCase)
278        );
279        assert_eq!(parse_case_format("unknown"), None);
280    }
281
282    #[test]
283    fn test_unknown_fields_ignored() {
284        let json = r#"{
285            "test": {
286                "steps": ["clean"],
287                "clean": {
288                    "remove_trailing": true,
289                    "some_future_field": 42
290                }
291            }
292        }"#;
293
294        // serde default behavior: unknown fields cause an error unless denied
295        // We want to test current behavior
296        let result: Result<ReformatConfig, _> = serde_json::from_str(json);
297        // By default serde_json ignores unknown fields
298        assert!(result.is_ok());
299    }
300}