1use std::collections::HashMap;
7
8use serde::Deserialize;
9
10use crate::case::CaseFormat;
11use crate::rename::{CaseTransform, SpaceReplace};
12
13pub type ReformatConfig = HashMap<String, Preset>;
15
16#[derive(Debug, Clone, Deserialize)]
18pub struct Preset {
19 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
42pub const VALID_STEPS: &[&str] = &[
44 "rename", "emojis", "clean", "convert", "group", "endings", "indent", "replace", "header",
45];
46
47pub 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#[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#[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#[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#[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#[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#[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#[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#[derive(Debug, Clone, Deserialize)]
159pub struct ReplacePatternEntry {
160 pub find: String,
161 pub replace: String,
162}
163
164#[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#[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 let result: Result<ReformatConfig, _> = serde_json::from_str(json);
351 assert!(result.is_ok());
353 }
354}