Skip to main content

confetti_rs/
mapper.rs

1use std::error::Error;
2use std::fmt;
3use std::fs;
4use std::io;
5use std::path::Path;
6
7use crate::{parse, ConfDirective, ConfOptions};
8
9/// Error type for mapping operations
10#[derive(Debug)]
11pub enum MapperError {
12    /// Error during parsing
13    ParseError(String),
14    /// Error during serialization
15    SerializeError(String),
16    /// Error during file I/O
17    IoError(io::Error),
18    /// Error during value conversion
19    ConversionError(String),
20    /// Error when a required field is missing
21    MissingField(String),
22}
23
24impl Error for MapperError {}
25
26impl fmt::Display for MapperError {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        match self {
29            MapperError::ParseError(msg) => write!(f, "Parse error: {}", msg),
30            MapperError::SerializeError(msg) => write!(f, "Serialization error: {}", msg),
31            MapperError::IoError(err) => write!(f, "I/O error: {}", err),
32            MapperError::ConversionError(msg) => write!(f, "Conversion error: {}", msg),
33            MapperError::MissingField(name) => write!(f, "Missing required field: {}", name),
34        }
35    }
36}
37
38impl From<io::Error> for MapperError {
39    fn from(error: io::Error) -> Self {
40        MapperError::IoError(error)
41    }
42}
43
44impl From<crate::ConfError> for MapperError {
45    fn from(error: crate::ConfError) -> Self {
46        MapperError::ParseError(error.to_string())
47    }
48}
49
50/// Trait for types that can be mapped from configuration
51pub trait FromConf: Sized {
52    /// Convert from a configuration directive to the implementing type
53    fn from_directive(directive: &ConfDirective) -> Result<Self, MapperError>;
54
55    /// Create an instance from a configuration string
56    fn from_str(s: &str) -> Result<Self, MapperError> {
57        let options = MapperOptions::default().parser_options;
58        let conf_unit = parse(s, options)?;
59
60        if conf_unit.directives.is_empty() {
61            return Err(MapperError::ParseError("No directives found".into()));
62        }
63
64        Self::from_directive(&conf_unit.directives[0])
65    }
66
67    /// Create an instance from a file
68    fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, MapperError> {
69        let content = fs::read_to_string(path)?;
70        Self::from_str(&content)
71    }
72}
73
74/// Trait for types that can be mapped to configuration
75pub trait ToConf {
76    /// Convert the implementing type to a configuration directive
77    fn to_directive(&self) -> Result<ConfDirective, MapperError>;
78
79    /// Convert the implementing type to a configuration string
80    fn to_string(&self) -> Result<String, MapperError> {
81        let directive = self.to_directive()?;
82
83        // Simple serialization for now - can be enhanced later
84        let mut result = String::new();
85        serialize_directive(&directive, &mut result, 0)?;
86
87        Ok(result)
88    }
89
90    /// Write the implementing type to a file
91    fn to_file<P: AsRef<Path>>(&self, path: P) -> Result<(), MapperError> {
92        let content = self.to_string()?;
93        fs::write(path, content)?;
94        Ok(())
95    }
96}
97
98/// Options for mapper configuration
99#[derive(Debug, Clone)]
100pub struct MapperOptions {
101    /// Options for the parser
102    pub parser_options: ConfOptions,
103    /// Whether field names should be converted to kebab-case in the config
104    pub use_kebab_case: bool,
105    /// Indentation string to use when writing configs (defaults to 2 spaces)
106    pub indent: String,
107}
108
109impl Default for MapperOptions {
110    fn default() -> Self {
111        Self {
112            parser_options: ConfOptions::default(),
113            use_kebab_case: false,
114            indent: "  ".to_string(),
115        }
116    }
117}
118
119// Helper function to convert to kebab case
120#[allow(dead_code)]
121fn to_kebab_case(s: &str) -> String {
122    let mut result = String::new();
123    let mut prev_is_lowercase = false;
124
125    for c in s.chars() {
126        if c.is_uppercase() {
127            if prev_is_lowercase {
128                result.push('-');
129            }
130            result.push(c.to_lowercase().next().unwrap());
131            prev_is_lowercase = false;
132        } else {
133            result.push(c);
134            prev_is_lowercase = true;
135        }
136    }
137
138    result
139}
140
141// Helper function to convert from kebab case
142#[allow(dead_code)]
143fn from_kebab_case(s: &str) -> String {
144    let mut result = String::new();
145    let mut capitalize_next = false;
146
147    for c in s.chars() {
148        if c == '-' {
149            capitalize_next = true;
150        } else if capitalize_next {
151            result.push(c.to_uppercase().next().unwrap());
152            capitalize_next = false;
153        } else {
154            result.push(c);
155        }
156    }
157
158    result
159}
160
161// Private helper function to serialize a directive
162fn serialize_directive(
163    directive: &ConfDirective,
164    output: &mut String,
165    depth: usize,
166) -> Result<(), MapperError> {
167    // Get indent string based on depth
168    let indent = "  ".repeat(depth);
169
170    // Write directive name
171    output.push_str(&indent);
172    output.push_str(&directive.name.value);
173
174    // Write arguments
175    for arg in &directive.arguments {
176        output.push(' ');
177        if arg.is_quoted {
178            output.push('"');
179            // Remove quotes if they already exist in the value
180            let mut value = if arg.value.starts_with('"') && arg.value.ends_with('"') {
181                arg.value[1..arg.value.len() - 1].to_string()
182            } else {
183                arg.value.clone()
184            };
185
186            // Remove trailing commas from string values
187            value = value.trim_end_matches(',').to_string();
188
189            output.push_str(&value);
190            output.push('"');
191        } else {
192            output.push_str(&arg.value);
193        }
194    }
195
196    if directive.children.is_empty() {
197        output.push_str(";\n");
198    } else {
199        output.push_str(" {\n");
200
201        // Write children
202        for child in &directive.children {
203            serialize_directive(child, output, depth + 1)?;
204        }
205
206        output.push_str(&indent);
207        output.push_str("}\n");
208    }
209
210    Ok(())
211}
212
213/// Value converter trait for converting between config strings and Rust types
214pub trait ValueConverter: Sized {
215    /// Convert from a string to this type
216    fn from_conf_value(value: &str) -> Result<Self, MapperError>;
217
218    /// Convert this type to a string representation
219    fn to_conf_value(&self) -> Result<String, MapperError>;
220
221    /// Determine if this type requires quotes when serialized
222    fn requires_quotes(&self) -> bool {
223        true // By default all types require quotes, except for those that override this method
224    }
225}
226
227// Implementation for primitive types
228
229impl ValueConverter for String {
230    fn from_conf_value(value: &str) -> Result<Self, MapperError> {
231        Ok(value.to_string())
232    }
233
234    fn to_conf_value(&self) -> Result<String, MapperError> {
235        // Remove leading and trailing quotes if they exist
236        let value = if self.starts_with('"') && self.ends_with('"') {
237            &self[1..self.len() - 1]
238        } else {
239            &self[..]
240        };
241
242        // Remove trailing commas
243        let value = value.trim_end_matches(',');
244
245        Ok(value.to_string())
246    }
247
248    fn requires_quotes(&self) -> bool {
249        true
250    }
251}
252
253impl ValueConverter for bool {
254    fn from_conf_value(value: &str) -> Result<Self, MapperError> {
255        match value.to_lowercase().as_str() {
256            "true" | "yes" | "on" | "1" => Ok(true),
257            "false" | "no" | "off" | "0" => Ok(false),
258            _ => Err(MapperError::ConversionError(format!(
259                "Cannot convert '{}' to bool",
260                value
261            ))),
262        }
263    }
264
265    fn to_conf_value(&self) -> Result<String, MapperError> {
266        Ok(self.to_string())
267    }
268
269    fn requires_quotes(&self) -> bool {
270        false
271    }
272}
273
274impl ValueConverter for i32 {
275    fn from_conf_value(value: &str) -> Result<Self, MapperError> {
276        value.parse::<i32>().map_err(|e| {
277            MapperError::ConversionError(format!("Cannot convert '{}' to i32: {}", value, e))
278        })
279    }
280
281    fn to_conf_value(&self) -> Result<String, MapperError> {
282        Ok(self.to_string())
283    }
284
285    fn requires_quotes(&self) -> bool {
286        false
287    }
288}
289
290impl ValueConverter for f64 {
291    fn from_conf_value(value: &str) -> Result<Self, MapperError> {
292        value.parse::<f64>().map_err(|e| {
293            MapperError::ConversionError(format!("Cannot convert '{}' to f64: {}", value, e))
294        })
295    }
296
297    fn to_conf_value(&self) -> Result<String, MapperError> {
298        Ok(self.to_string())
299    }
300
301    fn requires_quotes(&self) -> bool {
302        false
303    }
304}
305
306impl<T: ValueConverter> ValueConverter for Option<T> {
307    fn from_conf_value(value: &str) -> Result<Self, MapperError> {
308        if value.trim().is_empty() {
309            Ok(None)
310        } else {
311            Ok(Some(T::from_conf_value(value)?))
312        }
313    }
314
315    fn to_conf_value(&self) -> Result<String, MapperError> {
316        match self {
317            Some(val) => val.to_conf_value(),
318            None => Ok("".to_string()),
319        }
320    }
321
322    fn requires_quotes(&self) -> bool {
323        match self {
324            Some(val) => val.requires_quotes(),
325            None => false,
326        }
327    }
328}
329
330impl<T: ValueConverter> ValueConverter for Vec<T> {
331    fn from_conf_value(value: &str) -> Result<Self, MapperError> {
332        let values = value
333            .split(',')
334            .map(|s| s.trim())
335            .filter(|s| !s.is_empty())
336            .map(|s| T::from_conf_value(s))
337            .collect::<Result<Vec<T>, _>>()?;
338
339        Ok(values)
340    }
341
342    fn to_conf_value(&self) -> Result<String, MapperError> {
343        let values: Result<Vec<String>, _> = self.iter().map(|val| val.to_conf_value()).collect();
344
345        Ok(values?.join(", "))
346    }
347
348    fn requires_quotes(&self) -> bool {
349        // Vec always serializes as a string with commas
350        true
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use crate::{ConfArgument, ConfDirective};
358
359    #[test]
360    fn test_serialize_string_without_comma() {
361        // Create a test directive with a string value that has a comma
362        let directive = ConfDirective {
363            name: ConfArgument {
364                value: "TestConfig".to_string(),
365                span: 0..0,
366                is_quoted: false,
367                is_triple_quoted: false,
368                is_expression: false,
369            },
370            arguments: vec![],
371            children: vec![ConfDirective {
372                name: ConfArgument {
373                    value: "host".to_string(),
374                    span: 0..0,
375                    is_quoted: false,
376                    is_triple_quoted: false,
377                    is_expression: false,
378                },
379                arguments: vec![ConfArgument {
380                    value: "127.0.0.1,".to_string(),
381                    span: 0..0,
382                    is_quoted: true,
383                    is_triple_quoted: false,
384                    is_expression: false,
385                }],
386                children: vec![],
387            }],
388        };
389
390        // Serialize the directive
391        let mut output = String::new();
392        serialize_directive(&directive, &mut output, 0).unwrap();
393
394        // Verify the output has the comma removed
395        assert!(output.contains("\"127.0.0.1\""));
396        assert!(!output.contains("\"127.0.0.1,\""));
397    }
398
399    #[test]
400    fn test_serialize_numeric_without_quotes() {
401        // Create a test directive with a numeric value
402        let directive = ConfDirective {
403            name: ConfArgument {
404                value: "TestConfig".to_string(),
405                span: 0..0,
406                is_quoted: false,
407                is_triple_quoted: false,
408                is_expression: false,
409            },
410            arguments: vec![],
411            children: vec![ConfDirective {
412                name: ConfArgument {
413                    value: "port".to_string(),
414                    span: 0..0,
415                    is_quoted: false,
416                    is_triple_quoted: false,
417                    is_expression: false,
418                },
419                arguments: vec![ConfArgument {
420                    value: "3000".to_string(),
421                    span: 0..0,
422                    is_quoted: false,
423                    is_triple_quoted: false,
424                    is_expression: false,
425                }],
426                children: vec![],
427            }],
428        };
429
430        // Serialize the directive
431        let mut output = String::new();
432        serialize_directive(&directive, &mut output, 0).unwrap();
433
434        // Verify the output has no quotes for the numeric value
435        assert!(output.contains("port 3000;"));
436        assert!(!output.contains("port \"3000\";"));
437    }
438
439    #[test]
440    fn test_server_config_serialization() {
441        // Test case similar to the reported issue
442        let directive = ConfDirective {
443            name: ConfArgument {
444                value: "ServerConfig".to_string(),
445                span: 0..0,
446                is_quoted: false,
447                is_triple_quoted: false,
448                is_expression: false,
449            },
450            arguments: vec![],
451            children: vec![
452                ConfDirective {
453                    name: ConfArgument {
454                        value: "host".to_string(),
455                        span: 0..0,
456                        is_quoted: false,
457                        is_triple_quoted: false,
458                        is_expression: false,
459                    },
460                    arguments: vec![ConfArgument {
461                        value: "127.0.0.1,".to_string(),
462                        span: 0..0,
463                        is_quoted: true,
464                        is_triple_quoted: false,
465                        is_expression: false,
466                    }],
467                    children: vec![],
468                },
469                ConfDirective {
470                    name: ConfArgument {
471                        value: "port".to_string(),
472                        span: 0..0,
473                        is_quoted: false,
474                        is_triple_quoted: false,
475                        is_expression: false,
476                    },
477                    arguments: vec![ConfArgument {
478                        value: "3000".to_string(),
479                        span: 0..0,
480                        is_quoted: false,
481                        is_triple_quoted: false,
482                        is_expression: false,
483                    }],
484                    children: vec![],
485                },
486            ],
487        };
488
489        // Serialize the directive
490        let mut output = String::new();
491        serialize_directive(&directive, &mut output, 0).unwrap();
492
493        // Expected output should be correct
494        let expected = "ServerConfig {\n  host \"127.0.0.1\";\n  port 3000;\n}\n";
495
496        assert_eq!(output, expected);
497    }
498
499    #[test]
500    fn test_to_conf_value_string_with_quotes() {
501        // Test that string values with existing quotes have them removed
502        let value = "\"test value\"".to_string();
503        let result = value.to_conf_value().unwrap();
504        assert_eq!(result, "test value");
505    }
506
507    #[test]
508    fn test_to_conf_value_string_with_comma() {
509        // Test that string values with trailing commas have them removed
510        let value = "test value,".to_string();
511        let result = value.to_conf_value().unwrap();
512        assert_eq!(result, "test value");
513    }
514
515    #[test]
516    fn test_requires_quotes() {
517        // Test that string values require quotes
518        let string_value = String::from("test");
519        assert!(string_value.requires_quotes());
520
521        // Test that numeric values don't require quotes
522        let int_value = 3000;
523        assert!(!int_value.requires_quotes());
524
525        let float_value = std::f64::consts::PI;
526        assert!(!float_value.requires_quotes());
527
528        // Test that boolean values don't require quotes
529        let bool_value = true;
530        assert!(!bool_value.requires_quotes());
531    }
532}