mongosh/utils/
mod.rs

1//! Utility functions and helpers for mongosh
2//!
3//! This module provides common utility functions used throughout the application:
4//! - String manipulation and formatting
5//! - Time and duration utilities
6//! - File system helpers
7//! - Validation functions
8//! - Conversion utilities
9
10use std::path::{Path, PathBuf};
11use std::time::{Duration, SystemTime, UNIX_EPOCH};
12
13use crate::error::Result;
14
15/// String utilities
16pub mod string {
17    /// Truncate string to maximum length
18    ///
19    /// # Arguments
20    /// * `s` - String to truncate
21    /// * `max_len` - Maximum length
22    ///
23    /// # Returns
24    /// * `String` - Truncated string with ellipsis if needed
25    pub fn truncate(s: &str, max_len: usize) -> String {
26        if s.len() <= max_len {
27            s.to_string()
28        } else {
29            format!("{}...", &s[..max_len.saturating_sub(3)])
30        }
31    }
32
33    /// Check if string is a valid identifier
34    ///
35    /// # Arguments
36    /// * `s` - String to check
37    ///
38    /// # Returns
39    /// * `bool` - True if valid identifier
40    pub fn is_valid_identifier(s: &str) -> bool {
41        if s.is_empty() {
42            return false;
43        }
44
45        let first = s.chars().next().unwrap();
46        if !first.is_alphabetic() && first != '_' && first != '$' {
47            return false;
48        }
49
50        s.chars()
51            .all(|c| c.is_alphanumeric() || c == '_' || c == '$')
52    }
53
54    /// Convert snake_case to camelCase
55    ///
56    /// # Arguments
57    /// * `s` - Snake case string
58    ///
59    /// # Returns
60    /// * `String` - Camel case string
61    pub fn snake_to_camel(s: &str) -> String {
62        let mut result = String::new();
63        let mut capitalize_next = false;
64
65        for c in s.chars() {
66            if c == '_' {
67                capitalize_next = true;
68            } else if capitalize_next {
69                result.push(c.to_ascii_uppercase());
70                capitalize_next = false;
71            } else {
72                result.push(c);
73            }
74        }
75
76        result
77    }
78
79    /// Escape special characters for JSON
80    ///
81    /// # Arguments
82    /// * `s` - String to escape
83    ///
84    /// # Returns
85    /// * `String` - Escaped string
86    pub fn escape_json(s: &str) -> String {
87        s.replace('\\', "\\\\")
88            .replace('"', "\\\"")
89            .replace('\n', "\\n")
90            .replace('\r', "\\r")
91            .replace('\t', "\\t")
92    }
93}
94
95/// Time and duration utilities
96pub mod time {
97    use super::*;
98
99    /// Get current timestamp in milliseconds
100    ///
101    /// # Returns
102    /// * `u64` - Timestamp in milliseconds
103    pub fn now_millis() -> u64 {
104        SystemTime::now()
105            .duration_since(UNIX_EPOCH)
106            .unwrap_or(Duration::from_secs(0))
107            .as_millis() as u64
108    }
109
110    /// Get current timestamp in seconds
111    ///
112    /// # Returns
113    /// * `u64` - Timestamp in seconds
114    pub fn now_secs() -> u64 {
115        SystemTime::now()
116            .duration_since(UNIX_EPOCH)
117            .unwrap_or(Duration::from_secs(0))
118            .as_secs()
119    }
120
121    /// Format duration as human-readable string
122    ///
123    /// # Arguments
124    /// * `duration` - Duration to format
125    ///
126    /// # Returns
127    /// * `String` - Formatted duration (e.g., "1h 30m 45s")
128    pub fn format_duration(duration: Duration) -> String {
129        let secs = duration.as_secs();
130        let millis = duration.subsec_millis();
131
132        if secs == 0 {
133            return format!("{}ms", millis);
134        }
135
136        let hours = secs / 3600;
137        let minutes = (secs % 3600) / 60;
138        let seconds = secs % 60;
139
140        let mut parts = Vec::new();
141
142        if hours > 0 {
143            parts.push(format!("{}h", hours));
144        }
145        if minutes > 0 {
146            parts.push(format!("{}m", minutes));
147        }
148        if seconds > 0 || parts.is_empty() {
149            parts.push(format!("{}s", seconds));
150        }
151
152        parts.join(" ")
153    }
154
155    /// Parse duration string (e.g., "30s", "5m", "1h")
156    ///
157    /// # Arguments
158    /// * `s` - Duration string
159    ///
160    /// # Returns
161    /// * `Option<Duration>` - Parsed duration or None
162    pub fn parse_duration(s: &str) -> Option<Duration> {
163        let s = s.trim();
164        if s.is_empty() {
165            return None;
166        }
167
168        let (num_str, unit) = s.split_at(s.len() - 1);
169        let num: u64 = num_str.parse().ok()?;
170
171        match unit {
172            "s" => Some(Duration::from_secs(num)),
173            "m" => Some(Duration::from_secs(num * 60)),
174            "h" => Some(Duration::from_secs(num * 3600)),
175            _ => None,
176        }
177    }
178}
179
180/// File system utilities
181pub mod fs {
182    use super::*;
183
184    /// Ensure directory exists, create if not
185    ///
186    /// # Arguments
187    /// * `path` - Directory path
188    ///
189    /// # Returns
190    /// * `Result<()>` - Success or error
191    pub fn ensure_dir_exists<P: AsRef<Path>>(path: P) -> Result<()> {
192        let path = path.as_ref();
193        if !path.exists() {
194            std::fs::create_dir_all(path)?;
195        }
196        Ok(())
197    }
198
199    /// Get file extension
200    ///
201    /// # Arguments
202    /// * `path` - File path
203    ///
204    /// # Returns
205    /// * `Option<String>` - File extension or None
206    pub fn get_extension<P: AsRef<Path>>(path: P) -> Option<String> {
207        path.as_ref()
208            .extension()
209            .and_then(|e| e.to_str())
210            .map(|s| s.to_string())
211    }
212
213    /// Check if path is a valid file
214    ///
215    /// # Arguments
216    /// * `path` - Path to check
217    ///
218    /// # Returns
219    /// * `bool` - True if valid file
220    pub fn is_valid_file<P: AsRef<Path>>(path: P) -> bool {
221        let path = path.as_ref();
222        path.exists() && path.is_file()
223    }
224
225    /// Expand home directory in path
226    ///
227    /// # Arguments
228    /// * `path` - Path potentially starting with ~
229    ///
230    /// # Returns
231    /// * `PathBuf` - Expanded path
232    pub fn expand_home(path: &str) -> PathBuf {
233        if path.starts_with("~/")
234            && let Some(home) = dirs::home_dir() {
235                return home.join(&path[2..]);
236            }
237        PathBuf::from(path)
238    }
239}
240
241/// Validation utilities
242pub mod validate {
243    /// Validate MongoDB database name
244    ///
245    /// # Arguments
246    /// * `name` - Database name to validate
247    ///
248    /// # Returns
249    /// * `bool` - True if valid
250    pub fn is_valid_database_name(name: &str) -> bool {
251        if name.is_empty() || name.len() > 64 {
252            return false;
253        }
254
255        let invalid_chars = ['/', '\\', '.', ' ', '"', '$', '*', '<', '>', ':', '|', '?'];
256        !name.chars().any(|c| invalid_chars.contains(&c))
257    }
258
259    /// Validate MongoDB collection name
260    ///
261    /// # Arguments
262    /// * `name` - Collection name to validate
263    ///
264    /// # Returns
265    /// * `bool` - True if valid
266    pub fn is_valid_collection_name(name: &str) -> bool {
267        if name.is_empty() || name.len() > 120 {
268            return false;
269        }
270
271        if name.starts_with("system.") {
272            return false;
273        }
274
275        let invalid_chars = ['$', '\0'];
276        !name.chars().any(|c| invalid_chars.contains(&c))
277    }
278
279    /// Validate MongoDB connection URI
280    ///
281    /// # Arguments
282    /// * `uri` - Connection URI to validate
283    ///
284    /// # Returns
285    /// * `bool` - True if valid format
286    pub fn is_valid_connection_uri(uri: &str) -> bool {
287        uri.starts_with("mongodb://") || uri.starts_with("mongodb+srv://")
288    }
289
290    /// Validate field name
291    ///
292    /// # Arguments
293    /// * `name` - Field name to validate
294    ///
295    /// # Returns
296    /// * `bool` - True if valid
297    pub fn is_valid_field_name(name: &str) -> bool {
298        if name.is_empty() {
299            return false;
300        }
301
302        // Field names cannot start with $ (except for operators)
303        if name.starts_with('$') && !name.starts_with("$set") && !name.starts_with("$inc") {
304            return false;
305        }
306
307        // Field names cannot contain null character
308        !name.contains('\0')
309    }
310}
311
312/// Conversion utilities
313pub mod convert {
314    use mongodb::bson::Bson;
315
316    /// Convert Bson value to human-readable string
317    ///
318    /// # Arguments
319    /// * `value` - Bson value
320    ///
321    /// # Returns
322    /// * `String` - String representation
323    pub fn bson_to_string(value: &Bson) -> String {
324        match value {
325            Bson::Double(v) => format!("{}", v),
326            Bson::String(v) => v.clone(),
327            Bson::Boolean(v) => format!("{}", v),
328            Bson::Int32(v) => format!("{}", v),
329            Bson::Int64(v) => format!("{}", v),
330            Bson::Null => "null".to_string(),
331            _ => format!("{:?}", value),
332        }
333    }
334
335    /// Format bytes as human-readable size
336    ///
337    /// # Arguments
338    /// * `bytes` - Number of bytes
339    ///
340    /// # Returns
341    /// * `String` - Formatted size (e.g., "1.5 MB")
342    pub fn format_bytes(bytes: u64) -> String {
343        const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
344        let mut size = bytes as f64;
345        let mut unit_index = 0;
346
347        while size >= 1024.0 && unit_index < UNITS.len() - 1 {
348            size /= 1024.0;
349            unit_index += 1;
350        }
351
352        if unit_index == 0 {
353            format!("{} {}", size as u64, UNITS[unit_index])
354        } else {
355            format!("{:.2} {}", size, UNITS[unit_index])
356        }
357    }
358
359    /// Parse human-readable size to bytes
360    ///
361    /// # Arguments
362    /// * `s` - Size string (e.g., "10MB")
363    ///
364    /// # Returns
365    /// * `Option<u64>` - Size in bytes or None
366    pub fn parse_bytes(s: &str) -> Option<u64> {
367        let s = s.trim().to_uppercase();
368        let (num_str, unit) = if s.ends_with("TB") {
369            (s.trim_end_matches("TB"), 1024u64.pow(4))
370        } else if s.ends_with("GB") {
371            (s.trim_end_matches("GB"), 1024u64.pow(3))
372        } else if s.ends_with("MB") {
373            (s.trim_end_matches("MB"), 1024u64.pow(2))
374        } else if s.ends_with("KB") {
375            (s.trim_end_matches("KB"), 1024u64)
376        } else if s.ends_with('B') {
377            (s.trim_end_matches('B'), 1)
378        } else {
379            return s.parse().ok();
380        };
381
382        num_str
383            .trim()
384            .parse::<f64>()
385            .ok()
386            .map(|n| (n * unit as f64) as u64)
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    #[test]
395    fn test_truncate() {
396        assert_eq!(string::truncate("hello", 10), "hello");
397        assert_eq!(string::truncate("hello world", 8), "hello...");
398    }
399
400    #[test]
401    fn test_valid_identifier() {
402        assert!(string::is_valid_identifier("myVar"));
403        assert!(string::is_valid_identifier("_private"));
404        assert!(string::is_valid_identifier("$special"));
405        assert!(!string::is_valid_identifier("123invalid"));
406        assert!(!string::is_valid_identifier(""));
407    }
408
409    #[test]
410    fn test_snake_to_camel() {
411        assert_eq!(string::snake_to_camel("hello_world"), "helloWorld");
412        assert_eq!(string::snake_to_camel("my_var_name"), "myVarName");
413    }
414
415    #[test]
416    fn test_format_duration() {
417        assert_eq!(time::format_duration(Duration::from_secs(0)), "0ms");
418        assert_eq!(time::format_duration(Duration::from_secs(90)), "1m 30s");
419        assert_eq!(time::format_duration(Duration::from_secs(3661)), "1h 1m 1s");
420    }
421
422    #[test]
423    fn test_parse_duration() {
424        assert_eq!(time::parse_duration("30s"), Some(Duration::from_secs(30)));
425        assert_eq!(time::parse_duration("5m"), Some(Duration::from_secs(300)));
426        assert_eq!(time::parse_duration("1h"), Some(Duration::from_secs(3600)));
427        assert_eq!(time::parse_duration("invalid"), None);
428    }
429
430    #[test]
431    fn test_valid_database_name() {
432        assert!(validate::is_valid_database_name("mydb"));
433        assert!(validate::is_valid_database_name("test123"));
434        assert!(!validate::is_valid_database_name("my/db"));
435        assert!(!validate::is_valid_database_name(""));
436    }
437
438    #[test]
439    fn test_valid_collection_name() {
440        assert!(validate::is_valid_collection_name("users"));
441        assert!(validate::is_valid_collection_name("my_collection"));
442        assert!(!validate::is_valid_collection_name("system.users"));
443        assert!(!validate::is_valid_collection_name("invalid$name"));
444    }
445
446    #[test]
447    fn test_valid_connection_uri() {
448        assert!(validate::is_valid_connection_uri(
449            "mongodb://localhost:27017"
450        ));
451        assert!(validate::is_valid_connection_uri(
452            "mongodb+srv://cluster.example.com"
453        ));
454        assert!(!validate::is_valid_connection_uri("http://localhost"));
455    }
456
457    #[test]
458    fn test_format_bytes() {
459        assert_eq!(convert::format_bytes(500), "500 B");
460        assert_eq!(convert::format_bytes(1024), "1.00 KB");
461        assert_eq!(convert::format_bytes(1024 * 1024), "1.00 MB");
462    }
463
464    #[test]
465    fn test_parse_bytes() {
466        assert_eq!(convert::parse_bytes("1024"), Some(1024));
467        assert_eq!(convert::parse_bytes("1KB"), Some(1024));
468        assert_eq!(convert::parse_bytes("1MB"), Some(1024 * 1024));
469        assert_eq!(
470            convert::parse_bytes("1.5GB"),
471            Some((1.5 * 1024.0 * 1024.0 * 1024.0) as u64)
472        );
473    }
474}