Skip to main content

dotenv_space/core/
converter.rs

1// Converter trait and options for format conversion
2//
3// Provides the core trait and configuration for converting environment variables
4// to different output formats.
5
6use anyhow::Result;
7use std::collections::HashMap;
8
9/// Key transformation options
10#[derive(Debug, Clone)]
11pub enum KeyTransform {
12    /// Transform keys to UPPERCASE
13    Uppercase,
14    /// Transform keys to lowercase
15    Lowercase,
16    /// Transform keys to camelCase
17    CamelCase,
18    /// Transform keys to snake_case
19    SnakeCase,
20}
21
22/// Options for format conversion
23///
24/// ✅ CLIPPY FIX: Uses `#[derive(Default)]` instead of manual implementation
25#[derive(Debug, Clone, Default)]
26pub struct ConvertOptions {
27    /// Include only variables matching this glob pattern
28    pub include_pattern: Option<String>,
29
30    /// Exclude variables matching this glob pattern
31    pub exclude_pattern: Option<String>,
32
33    /// Base64-encode all values
34    pub base64: bool,
35
36    /// Prefix to add to all keys
37    pub prefix: Option<String>,
38
39    /// Key transformation to apply
40    pub transform: Option<KeyTransform>,
41}
42
43// ✅ CLIPPY FIX: Removed manual impl Default (using #[derive(Default)])
44
45impl ConvertOptions {
46    /// Create new default options
47    pub fn new() -> Self {
48        Self::default()
49    }
50
51    /// Filter variables based on include/exclude patterns
52    pub fn filter_vars(&self, vars: &HashMap<String, String>) -> HashMap<String, String> {
53        vars.iter()
54            .filter(|(key, _)| self.should_include(key))
55            .map(|(k, v)| (k.clone(), v.clone()))
56            .collect()
57    }
58
59    /// Check if a variable should be included
60    fn should_include(&self, key: &str) -> bool {
61        // Check exclude pattern first
62        if let Some(ref exclude) = self.exclude_pattern {
63            if glob_match(key, exclude) {
64                return false;
65            }
66        }
67
68        // Check include pattern
69        if let Some(ref include) = self.include_pattern {
70            return glob_match(key, include);
71        }
72
73        // Include by default if no patterns specified
74        true
75    }
76
77    /// Transform a key according to the specified transformation
78    pub fn transform_key(&self, key: &str) -> String {
79        let key = if let Some(ref prefix) = self.prefix {
80            format!("{}{}", prefix, key)
81        } else {
82            key.to_string()
83        };
84
85        match self.transform {
86            Some(KeyTransform::Uppercase) => key.to_uppercase(),
87            Some(KeyTransform::Lowercase) => key.to_lowercase(),
88            Some(KeyTransform::CamelCase) => to_camel_case(&key),
89            Some(KeyTransform::SnakeCase) => to_snake_case(&key),
90            None => key,
91        }
92    }
93
94    /// Transform a value (base64 encode if enabled)
95    pub fn transform_value(&self, value: &str) -> String {
96        if self.base64 {
97            use base64::{engine::general_purpose, Engine as _};
98            general_purpose::STANDARD.encode(value.as_bytes())
99        } else {
100            value.to_string()
101        }
102    }
103}
104
105/// Converter trait for format conversion
106pub trait Converter {
107    /// Convert environment variables to the target format
108    ///
109    /// # Arguments
110    ///
111    /// * `vars` - Environment variables to convert
112    /// * `options` - Conversion options (filtering, transformations, etc.)
113    ///
114    /// # Returns
115    ///
116    /// Formatted output as a string
117    fn convert(&self, vars: &HashMap<String, String>, options: &ConvertOptions) -> Result<String>;
118
119    /// Get the name of this format
120    ///
121    /// Used for format selection and error messages.
122    fn name(&self) -> &str;
123
124    /// Get a description of this format
125    ///
126    /// Used in help text and interactive selection.
127    fn description(&self) -> &str;
128}
129
130// Helper functions
131
132/// Simple glob pattern matching
133///
134/// Supports `*` wildcard matching.
135fn glob_match(text: &str, pattern: &str) -> bool {
136    if pattern == "*" {
137        return true;
138    }
139
140    if pattern.starts_with('*') && pattern.ends_with('*') {
141        let substring = &pattern[1..pattern.len() - 1];
142        return text.contains(substring);
143    }
144
145    // if pattern.starts_with('*') {
146    //     let suffix = &pattern[1..];
147    //     return text.ends_with(suffix);
148    // }
149
150    // if pattern.ends_with('*') {
151    //     let prefix = &pattern[..pattern.len() - 1];
152    //     return text.starts_with(prefix);
153    // }
154    if let Some(suffix) = pattern.strip_prefix('*') {
155        return text.ends_with(suffix);
156    }
157
158    if let Some(prefix) = pattern.strip_suffix('*') {
159        return text.starts_with(prefix);
160    }
161
162    text == pattern
163}
164
165/// Convert string to camelCase
166fn to_camel_case(s: &str) -> String {
167    let parts: Vec<&str> = s.split('_').collect();
168    if parts.is_empty() {
169        return String::new();
170    }
171
172    let mut result = parts[0].to_lowercase();
173    for part in &parts[1..] {
174        if !part.is_empty() {
175            let mut chars = part.chars();
176            if let Some(first) = chars.next() {
177                result.push_str(&first.to_uppercase().to_string());
178                result.push_str(&chars.as_str().to_lowercase());
179            }
180        }
181    }
182    result
183}
184
185// Convert string to snake_case
186// fn to_snake_case(s: &str) -> String {
187//     let mut result = String::with_capacity(s.len());
188//     let mut prev_is_upper = false;
189
190//     for (i, ch) in s.chars().enumerate() {
191//         if ch.is_uppercase() {
192//             if i > 0 && !prev_is_upper {
193//                 result.push('_');
194//             }
195//             result.extend(ch.to_lowercase());
196//             prev_is_upper = true;
197//         } else {
198//             result.push(ch);
199//             prev_is_upper = false;
200//         }
201//     }
202
203//     result
204// }
205
206fn to_snake_case(s: &str) -> String {
207    let mut result = String::with_capacity(s.len());
208    let chars: Vec<char> = s.chars().collect();
209
210    for i in 0..chars.len() {
211        let ch = chars[i];
212
213        if ch.is_uppercase() {
214            if i > 0 {
215                let prev = chars[i - 1];
216                let next = chars.get(i + 1);
217
218                if prev.is_lowercase()
219                    || (prev.is_uppercase() && next.map(|c| c.is_lowercase()).unwrap_or(false))
220                {
221                    result.push('_');
222                }
223            }
224            result.extend(ch.to_lowercase());
225        } else {
226            result.push(ch);
227        }
228    }
229
230    result
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn test_default_options() {
239        let opts = ConvertOptions::default();
240        assert!(opts.include_pattern.is_none());
241        assert!(opts.exclude_pattern.is_none());
242        assert!(!opts.base64);
243        assert!(opts.prefix.is_none());
244        assert!(opts.transform.is_none());
245    }
246
247    #[test]
248    fn test_new_options() {
249        let opts = ConvertOptions::new();
250        assert!(opts.include_pattern.is_none());
251    }
252
253    #[test]
254    fn test_filter_vars_no_pattern() {
255        let mut vars = HashMap::new();
256        vars.insert("KEY1".to_string(), "value1".to_string());
257        vars.insert("KEY2".to_string(), "value2".to_string());
258
259        let opts = ConvertOptions::default();
260        let filtered = opts.filter_vars(&vars);
261
262        assert_eq!(filtered.len(), 2);
263        assert!(filtered.contains_key("KEY1"));
264        assert!(filtered.contains_key("KEY2"));
265    }
266
267    #[test]
268    fn test_filter_vars_include() {
269        let mut vars = HashMap::new();
270        vars.insert("AWS_KEY".to_string(), "value1".to_string());
271        vars.insert("DB_KEY".to_string(), "value2".to_string());
272
273        let mut opts = ConvertOptions::default();
274        opts.include_pattern = Some("AWS_*".to_string());
275        let filtered = opts.filter_vars(&vars);
276
277        assert_eq!(filtered.len(), 1);
278        assert!(filtered.contains_key("AWS_KEY"));
279        assert!(!filtered.contains_key("DB_KEY"));
280    }
281
282    #[test]
283    fn test_filter_vars_exclude() {
284        let mut vars = HashMap::new();
285        vars.insert("KEY1".to_string(), "value1".to_string());
286        vars.insert("KEY2_LOCAL".to_string(), "value2".to_string());
287
288        let mut opts = ConvertOptions::default();
289        opts.exclude_pattern = Some("*_LOCAL".to_string());
290        let filtered = opts.filter_vars(&vars);
291
292        assert_eq!(filtered.len(), 1);
293        assert!(filtered.contains_key("KEY1"));
294        assert!(!filtered.contains_key("KEY2_LOCAL"));
295    }
296
297    #[test]
298    fn test_transform_key_prefix() {
299        let mut opts = ConvertOptions::default();
300        opts.prefix = Some("APP_".to_string());
301
302        assert_eq!(opts.transform_key("DATABASE_URL"), "APP_DATABASE_URL");
303    }
304
305    #[test]
306    fn test_transform_key_uppercase() {
307        let mut opts = ConvertOptions::default();
308        opts.transform = Some(KeyTransform::Uppercase);
309
310        assert_eq!(opts.transform_key("database_url"), "DATABASE_URL");
311    }
312
313    #[test]
314    fn test_transform_key_lowercase() {
315        let mut opts = ConvertOptions::default();
316        opts.transform = Some(KeyTransform::Lowercase);
317
318        assert_eq!(opts.transform_key("DATABASE_URL"), "database_url");
319    }
320
321    #[test]
322    fn test_transform_value_base64() {
323        let mut opts = ConvertOptions::default();
324        opts.base64 = true;
325
326        let result = opts.transform_value("hello");
327        assert_eq!(result, "aGVsbG8="); // base64("hello")
328    }
329
330    #[test]
331    fn test_glob_match() {
332        assert!(glob_match("AWS_KEY", "AWS_*"));
333        assert!(glob_match("MY_AWS_KEY", "*_AWS_*"));
334        assert!(glob_match("KEY_LOCAL", "*_LOCAL"));
335        assert!(glob_match("anything", "*"));
336        assert!(!glob_match("AWS_KEY", "DB_*"));
337    }
338
339    #[test]
340    fn test_to_camel_case() {
341        assert_eq!(to_camel_case("database_url"), "databaseUrl");
342        assert_eq!(to_camel_case("secret_key"), "secretKey");
343        assert_eq!(to_camel_case("aws_access_key_id"), "awsAccessKeyId");
344    }
345
346    #[test]
347    fn test_to_snake_case() {
348        assert_eq!(to_snake_case("DatabaseURL"), "database_url");
349        assert_eq!(to_snake_case("SecretKey"), "secret_key");
350        assert_eq!(to_snake_case("HTTPServer"), "http_server");
351        assert_eq!(to_snake_case("UserID"), "user_id");
352        assert_eq!(to_snake_case("MySQLDatabase"), "my_sql_database");
353        assert_eq!(to_snake_case("JSONParser"), "json_parser");
354        assert_eq!(to_snake_case("Already_snake"), "already_snake");
355        // assert_eq!(to_snake_case("DatabaseURL"), "database_u_r_l");
356    }
357}