1use crate::error::CliError;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct Field {
14 pub name: String,
16 pub rust_type: String,
18 pub sql_type: String,
20 pub is_nullable: bool,
22 pub is_primary_key: bool,
24 pub is_indexed: bool,
26}
27
28pub struct FieldParser;
30
31const TYPE_MAP: &[(&str, &str)] = &[
33 ("String", "VARCHAR(255)"),
34 ("i32", "INT"),
35 ("i64", "BIGINT"),
36 ("f64", "DOUBLE"),
37 ("bool", "BOOLEAN"),
38 ("DateTime", "DATETIME"),
39 ("Json", "JSON"),
40];
41
42const KNOWN_TYPES: &[&str] = &["String", "i32", "i64", "f64", "bool", "DateTime", "Json"];
44
45const MODIFIER_PK: &str = "pk";
47const MODIFIER_INDEX: &str = "index";
48
49impl FieldParser {
50 pub fn parse(input: &str) -> Result<Vec<Field>, CliError> {
63 let input = input.trim();
64
65 if input.is_empty() {
66 return Err(CliError::FieldParseError(
67 "field definition is empty".to_string(),
68 ));
69 }
70
71 if input.ends_with(',') {
72 return Err(CliError::FieldParseError(format!(
73 "trailing comma at end of field definition: '{input}'"
74 )));
75 }
76
77 let mut fields = Vec::new();
78 for (idx, part) in input.split(',').enumerate() {
79 let part = part.trim();
80 if part.is_empty() {
81 return Err(CliError::FieldParseError(format!(
82 "empty field at position {idx}"
83 )));
84 }
85 let field = Self::parse_single(part, idx)?;
86 fields.push(field);
87 }
88
89 Ok(fields)
90 }
91
92 fn parse_single(part: &str, idx: usize) -> Result<Field, CliError> {
94 let tokens: Vec<&str> = part.split(':').collect();
95 if tokens.len() < 2 {
96 return Err(CliError::FieldParseError(format!(
97 "missing ':' in field definition '{part}' at position {idx}"
98 )));
99 }
100
101 let name = tokens[0].trim();
102 let mut rust_type = tokens[1].trim().to_string();
103 let mut is_nullable = false;
104 let mut is_primary_key = false;
105 let mut is_indexed = false;
106
107 if rust_type.ends_with('?') {
108 is_nullable = true;
109 rust_type = rust_type[..rust_type.len() - 1].to_string();
110 }
111
112 for modifier in tokens.iter().skip(2) {
113 let modifier = modifier.trim();
114 match modifier {
115 MODIFIER_PK => is_primary_key = true,
116 MODIFIER_INDEX => is_indexed = true,
117 other => {
118 return Err(CliError::FieldParseError(format!(
119 "unknown modifier '{other}' in field definition '{part}' at position {idx}"
120 )));
121 }
122 }
123 }
124
125 Self::validate_name(name, idx)?;
126 Self::validate_type(&rust_type, idx)?;
127
128 let sql_type = Self::rust_type_to_sql(&rust_type)?;
129
130 Ok(Field {
131 name: name.to_string(),
132 rust_type,
133 sql_type,
134 is_nullable,
135 is_primary_key,
136 is_indexed,
137 })
138 }
139
140 fn validate_name(name: &str, idx: usize) -> Result<(), CliError> {
142 if name.is_empty() {
143 return Err(CliError::FieldParseError(format!(
144 "empty field name at position {idx}"
145 )));
146 }
147
148 if name
149 .chars()
150 .next()
151 .map(|c| c.is_ascii_digit())
152 .unwrap_or(false)
153 {
154 return Err(CliError::FieldParseError(format!(
155 "field name '{name}' at position {idx} starts with a digit"
156 )));
157 }
158
159 if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
160 return Err(CliError::FieldParseError(format!(
161 "field name '{name}' at position {idx} contains invalid characters (only letters, digits, and underscores are allowed)"
162 )));
163 }
164
165 Ok(())
166 }
167
168 fn validate_type(rust_type: &str, idx: usize) -> Result<(), CliError> {
170 if !KNOWN_TYPES.contains(&rust_type) {
171 return Err(CliError::FieldParseError(format!(
172 "unknown type '{rust_type}' at position {idx}. Known types: {}",
173 KNOWN_TYPES.join(", ")
174 )));
175 }
176 Ok(())
177 }
178
179 pub fn rust_type_to_sql(rust_type: &str) -> Result<String, CliError> {
181 for (rust, sql) in TYPE_MAP {
182 if *rust == rust_type {
183 return Ok(sql.to_string());
184 }
185 }
186 Err(CliError::FieldParseError(format!(
187 "unknown Rust type '{rust_type}'. Known types: {}",
188 KNOWN_TYPES.join(", ")
189 )))
190 }
191
192 pub fn known_types() -> &'static [&'static str] {
194 KNOWN_TYPES
195 }
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201
202 #[test]
203 fn test_parse_basic() {
204 let fields = FieldParser::parse("id:i32,name:String,age:i32").unwrap();
205 assert_eq!(fields.len(), 3);
206 assert_eq!(fields[0].name, "id");
207 assert_eq!(fields[0].rust_type, "i32");
208 assert_eq!(fields[0].sql_type, "INT");
209 assert_eq!(fields[1].name, "name");
210 assert_eq!(fields[1].rust_type, "String");
211 assert_eq!(fields[1].sql_type, "VARCHAR(255)");
212 }
213
214 #[test]
215 fn test_parse_with_datetime() {
216 let fields = FieldParser::parse("id:i32,name:String,created_at:DateTime").unwrap();
217 assert_eq!(fields.len(), 3);
218 assert_eq!(fields[2].name, "created_at");
219 assert_eq!(fields[2].rust_type, "DateTime");
220 assert_eq!(fields[2].sql_type, "DATETIME");
221 }
222
223 #[test]
224 fn test_parse_all_types() {
225 let fields =
226 FieldParser::parse("a:String,b:i32,c:i64,d:f64,e:bool,f:DateTime,g:Json").unwrap();
227 assert_eq!(fields.len(), 7);
228 assert_eq!(fields[0].sql_type, "VARCHAR(255)");
229 assert_eq!(fields[1].sql_type, "INT");
230 assert_eq!(fields[2].sql_type, "BIGINT");
231 assert_eq!(fields[3].sql_type, "DOUBLE");
232 assert_eq!(fields[4].sql_type, "BOOLEAN");
233 assert_eq!(fields[5].sql_type, "DATETIME");
234 assert_eq!(fields[6].sql_type, "JSON");
235 }
236
237 #[test]
238 fn test_parse_nullable() {
239 let fields = FieldParser::parse("id:i32,name:String?").unwrap();
240 assert!(!fields[0].is_nullable);
241 assert!(fields[1].is_nullable);
242 assert_eq!(fields[1].rust_type, "String");
243 }
244
245 #[test]
246 fn test_parse_primary_key() {
247 let fields = FieldParser::parse("id:i32:pk,name:String").unwrap();
248 assert!(fields[0].is_primary_key);
249 assert!(!fields[1].is_primary_key);
250 }
251
252 #[test]
253 fn test_parse_indexed() {
254 let fields = FieldParser::parse("id:i32:pk,email:String:index").unwrap();
255 assert!(fields[0].is_primary_key);
256 assert!(fields[1].is_indexed);
257 }
258
259 #[test]
260 fn test_parse_empty_string() {
261 let result = FieldParser::parse("");
262 assert!(result.is_err());
263 assert!(matches!(result.unwrap_err(), CliError::FieldParseError(_)));
264 }
265
266 #[test]
267 fn test_parse_trailing_comma() {
268 let result = FieldParser::parse("id:i32,name:String,");
269 assert!(result.is_err());
270 let err = result.unwrap_err();
271 assert!(matches!(err, CliError::FieldParseError(_)));
272 assert!(err.to_string().contains("trailing comma"));
273 }
274
275 #[test]
276 fn test_parse_unknown_type() {
277 let result = FieldParser::parse("id:UnknownType");
278 assert!(result.is_err());
279 let err = result.unwrap_err();
280 assert!(matches!(err, CliError::FieldParseError(_)));
281 assert!(err.to_string().contains("unknown type"));
282 }
283
284 #[test]
285 fn test_parse_missing_colon() {
286 let result = FieldParser::parse("id_i32");
287 assert!(result.is_err());
288 let err = result.unwrap_err();
289 assert!(err.to_string().contains("missing ':'"));
290 }
291
292 #[test]
293 fn test_parse_name_starts_with_digit() {
294 let result = FieldParser::parse("1id:i32");
295 assert!(result.is_err());
296 let err = result.unwrap_err();
297 assert!(err.to_string().contains("starts with a digit"));
298 }
299
300 #[test]
301 fn test_parse_name_with_special_chars() {
302 let result = FieldParser::parse("na;me:i32");
303 assert!(result.is_err());
304 let err = result.unwrap_err();
305 assert!(err.to_string().contains("invalid characters"));
306 }
307
308 #[test]
309 fn test_parse_injection_semicolon() {
310 let result = FieldParser::parse("name:String;rm -rf /");
311 assert!(result.is_err());
312 }
313
314 #[test]
315 fn test_parse_injection_pipe() {
316 let result = FieldParser::parse("name:String|cat /etc/passwd");
317 assert!(result.is_err());
318 }
319
320 #[test]
321 fn test_parse_unknown_modifier() {
322 let result = FieldParser::parse("id:i32:foobar");
323 assert!(result.is_err());
324 let err = result.unwrap_err();
325 assert!(err.to_string().contains("unknown modifier"));
326 }
327
328 #[test]
329 fn test_parse_whitespace_trimming() {
330 let fields = FieldParser::parse(" id : i32 , name : String ").unwrap();
331 assert_eq!(fields.len(), 2);
332 assert_eq!(fields[0].name, "id");
333 assert_eq!(fields[1].name, "name");
334 }
335
336 #[test]
337 fn test_rust_type_to_sql() {
338 assert_eq!(
339 FieldParser::rust_type_to_sql("String").unwrap(),
340 "VARCHAR(255)"
341 );
342 assert_eq!(FieldParser::rust_type_to_sql("i32").unwrap(), "INT");
343 assert_eq!(FieldParser::rust_type_to_sql("i64").unwrap(), "BIGINT");
344 assert_eq!(FieldParser::rust_type_to_sql("f64").unwrap(), "DOUBLE");
345 assert_eq!(FieldParser::rust_type_to_sql("bool").unwrap(), "BOOLEAN");
346 assert_eq!(
347 FieldParser::rust_type_to_sql("DateTime").unwrap(),
348 "DATETIME"
349 );
350 assert_eq!(FieldParser::rust_type_to_sql("Json").unwrap(), "JSON");
351 }
352
353 #[test]
354 fn test_rust_type_to_sql_unknown() {
355 let result = FieldParser::rust_type_to_sql("Unknown");
356 assert!(result.is_err());
357 }
358
359 #[test]
360 fn test_known_types() {
361 let types = FieldParser::known_types();
362 assert_eq!(types.len(), 7);
363 assert!(types.contains(&"String"));
364 assert!(types.contains(&"Json"));
365 }
366}