1use regex::Regex;
10
11use crate::error::RulesError;
12use crate::model::{ConvertOp, Transform};
13
14pub struct CompiledTransform {
16 pub source: String,
18 op: Op,
19}
20
21enum Op {
22 Replace {
23 regex: Regex,
24 by: String,
25 },
26 Substring {
27 start: Option<i64>,
28 end: Option<i64>,
29 },
30 Convert(ConvertOp),
31}
32
33impl CompiledTransform {
34 pub fn compile(rule_id: &str, name: &str, t: &Transform) -> Result<Self, RulesError> {
36 let set = [
37 t.replace.is_some(),
38 t.substring.is_some(),
39 t.convert.is_some(),
40 ]
41 .into_iter()
42 .filter(|b| *b)
43 .count();
44 if set != 1 {
45 return Err(RulesError::PatternCompile {
46 rule: rule_id.to_string(),
47 message: format!(
48 "transform `{name}` must set exactly one of `replace` / `substring` / `convert`"
49 ),
50 });
51 }
52
53 let op = if let Some(r) = &t.replace {
54 Op::Replace {
55 regex: Regex::new(&r.regex).map_err(|err| RulesError::PatternCompile {
56 rule: rule_id.to_string(),
57 message: format!("transform `{name}`: invalid regex `{}`: {err}", r.regex),
58 })?,
59 by: r.by.clone(),
60 }
61 } else if let Some(s) = &t.substring {
62 Op::Substring {
63 start: s.start,
64 end: s.end,
65 }
66 } else {
67 Op::Convert(t.convert.expect("convert is set"))
68 };
69
70 Ok(CompiledTransform {
71 source: t.source.clone(),
72 op,
73 })
74 }
75
76 pub fn apply(&self, input: &str) -> String {
78 match &self.op {
79 Op::Replace { regex, by } => regex.replace_all(input, by.as_str()).into_owned(),
80 Op::Substring { start, end } => slice_chars(input, *start, *end),
81 Op::Convert(convert) => convert_case(input, *convert),
82 }
83 }
84}
85
86fn slice_chars(input: &str, start: Option<i64>, end: Option<i64>) -> String {
89 let chars: Vec<char> = input.chars().collect();
90 let len = chars.len() as i64;
91 let resolve = |idx: i64| -> usize {
92 let resolved = if idx < 0 { len + idx } else { idx };
93 resolved.clamp(0, len) as usize
94 };
95 let s = resolve(start.unwrap_or(0));
96 let e = resolve(end.unwrap_or(len));
97 if s >= e {
98 return String::new();
99 }
100 chars[s..e].iter().collect()
101}
102
103fn split_words(input: &str) -> Vec<String> {
106 let mut words = Vec::new();
107 let mut current = String::new();
108 let mut prev: Option<char> = None;
109 for ch in input.chars() {
110 if ch == '_' || ch == '-' || ch == ' ' || ch == '/' || ch == '.' {
111 if !current.is_empty() {
112 words.push(std::mem::take(&mut current));
113 }
114 prev = None;
115 continue;
116 }
117 if let Some(p) = prev {
119 let boundary = (p.is_lowercase() && ch.is_uppercase())
120 || (p.is_alphabetic() && ch.is_ascii_digit())
121 || (p.is_ascii_digit() && ch.is_alphabetic());
122 if boundary && !current.is_empty() {
123 words.push(std::mem::take(&mut current));
124 }
125 }
126 current.push(ch.to_ascii_lowercase());
127 prev = Some(ch);
128 }
129 if !current.is_empty() {
130 words.push(current);
131 }
132 words
133}
134
135fn capitalize(word: &str) -> String {
136 let mut chars = word.chars();
137 match chars.next() {
138 Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
139 None => String::new(),
140 }
141}
142
143fn convert_case(input: &str, convert: ConvertOp) -> String {
144 match convert {
145 ConvertOp::Lower => input.to_lowercase(),
146 ConvertOp::Upper => input.to_uppercase(),
147 ConvertOp::Snake => split_words(input).join("_"),
148 ConvertOp::ScreamingSnake => split_words(input)
149 .iter()
150 .map(|w| w.to_uppercase())
151 .collect::<Vec<_>>()
152 .join("_"),
153 ConvertOp::Kebab => split_words(input).join("-"),
154 ConvertOp::UpperCamel => split_words(input).iter().map(|w| capitalize(w)).collect(),
155 ConvertOp::LowerCamel => {
156 let words = split_words(input);
157 let mut out = String::new();
158 for (i, w) in words.iter().enumerate() {
159 if i == 0 {
160 out.push_str(w);
161 } else {
162 out.push_str(&capitalize(w));
163 }
164 }
165 out
166 }
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173 use crate::model::{ReplaceOp, SubstringOp};
174
175 fn convert(input: &str, op: ConvertOp) -> String {
176 let t = Transform {
177 source: "X".into(),
178 replace: None,
179 substring: None,
180 convert: Some(op),
181 };
182 CompiledTransform::compile("r", "out", &t)
183 .unwrap()
184 .apply(input)
185 }
186
187 #[test]
188 fn case_conversions() {
189 assert_eq!(convert("user_id", ConvertOp::LowerCamel), "userId");
190 assert_eq!(convert("userId", ConvertOp::Snake), "user_id");
191 assert_eq!(convert("user-id", ConvertOp::UpperCamel), "UserId");
192 assert_eq!(convert("userId", ConvertOp::ScreamingSnake), "USER_ID");
193 assert_eq!(convert("userId", ConvertOp::Kebab), "user-id");
194 assert_eq!(convert("FooBar", ConvertOp::Lower), "foobar");
195 }
196
197 #[test]
198 fn regex_replace() {
199 let t = Transform {
200 source: "X".into(),
201 replace: Some(ReplaceOp {
202 regex: "Controller$".into(),
203 by: String::new(),
204 }),
205 substring: None,
206 convert: None,
207 };
208 let c = CompiledTransform::compile("r", "out", &t).unwrap();
209 assert_eq!(c.apply("UserController"), "User");
210 }
211
212 #[test]
213 fn substring_slice() {
214 let t = Transform {
215 source: "X".into(),
216 replace: None,
217 substring: Some(SubstringOp {
218 start: Some(0),
219 end: Some(3),
220 }),
221 convert: None,
222 };
223 let c = CompiledTransform::compile("r", "out", &t).unwrap();
224 assert_eq!(c.apply("abcdef"), "abc");
225 }
226
227 #[test]
228 fn substring_negative_end() {
229 let t = Transform {
230 source: "X".into(),
231 replace: None,
232 substring: Some(SubstringOp {
233 start: None,
234 end: Some(-1),
235 }),
236 convert: None,
237 };
238 let c = CompiledTransform::compile("r", "out", &t).unwrap();
239 assert_eq!(c.apply("hello"), "hell");
240 }
241
242 #[test]
243 fn rejects_zero_or_multiple_ops() {
244 let none = Transform {
245 source: "X".into(),
246 replace: None,
247 substring: None,
248 convert: None,
249 };
250 assert!(CompiledTransform::compile("r", "out", &none).is_err());
251 let two = Transform {
252 source: "X".into(),
253 replace: Some(ReplaceOp {
254 regex: "a".into(),
255 by: "b".into(),
256 }),
257 substring: None,
258 convert: Some(ConvertOp::Lower),
259 };
260 assert!(CompiledTransform::compile("r", "out", &two).is_err());
261 }
262}