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}
33
34pub const VALID_STEPS: &[&str] = &["rename", "emojis", "clean", "convert", "group"];
36
37pub 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#[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#[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#[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#[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#[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 let result: Result<ReformatConfig, _> = serde_json::from_str(json);
297 assert!(result.is_ok());
299 }
300}