Skip to main content

forge_core/util/
mod.rs

1//! Shared utility functions for the Forge framework.
2
3use std::time::Duration;
4
5/// Parse a duration string into `Duration`.
6///
7/// Supports the following suffixes:
8/// - `ms` - milliseconds
9/// - `s` - seconds
10/// - `m` - minutes
11/// - `h` - hours
12/// - `d` - days
13///
14/// If no suffix is provided, the value is interpreted as seconds.
15pub fn parse_duration(s: &str) -> Option<Duration> {
16    let s = s.trim();
17    if let Some(num) = s.strip_suffix("ms") {
18        num.parse::<u64>().ok().map(Duration::from_millis)
19    } else if let Some(num) = s.strip_suffix('s') {
20        num.parse::<u64>().ok().map(Duration::from_secs)
21    } else if let Some(num) = s.strip_suffix('m') {
22        num.parse::<u64>().ok().map(|m| Duration::from_secs(m * 60))
23    } else if let Some(num) = s.strip_suffix('h') {
24        num.parse::<u64>()
25            .ok()
26            .map(|h| Duration::from_secs(h * 3600))
27    } else if let Some(num) = s.strip_suffix('d') {
28        num.parse::<u64>()
29            .ok()
30            .map(|d| Duration::from_secs(d * 86400))
31    } else {
32        s.parse::<u64>().ok().map(Duration::from_secs)
33    }
34}
35
36/// Parse a human-readable size string into bytes.
37///
38/// Supports the following suffixes (case-insensitive):
39/// - `kb` - kilobytes
40/// - `mb` - megabytes
41/// - `gb` - gigabytes
42/// - `b` - bytes
43///
44/// If no suffix is provided, the value is interpreted as bytes.
45pub fn parse_size(s: &str) -> Option<usize> {
46    let s = s.trim().to_lowercase();
47    if let Some(num) = s.strip_suffix("gb") {
48        num.trim()
49            .parse::<usize>()
50            .ok()
51            .map(|n| n * 1024 * 1024 * 1024)
52    } else if let Some(num) = s.strip_suffix("mb") {
53        num.trim().parse::<usize>().ok().map(|n| n * 1024 * 1024)
54    } else if let Some(num) = s.strip_suffix("kb") {
55        num.trim().parse::<usize>().ok().map(|n| n * 1024)
56    } else if let Some(num) = s.strip_suffix('b') {
57        num.trim().parse::<usize>().ok()
58    } else {
59        s.parse::<usize>().ok()
60    }
61}
62
63/// Convert a snake_case string to PascalCase.
64pub fn to_pascal_case(s: &str) -> String {
65    s.split('_')
66        .map(|part| {
67            let mut chars = part.chars();
68            match chars.next() {
69                None => String::new(),
70                Some(first) => first.to_uppercase().chain(chars).collect(),
71            }
72        })
73        .collect()
74}
75
76/// Convert a PascalCase or camelCase string to snake_case.
77pub fn to_snake_case(s: &str) -> String {
78    let chars: Vec<char> = s.chars().collect();
79    let mut result = String::new();
80    for (i, &c) in chars.iter().enumerate() {
81        if c.is_uppercase() {
82            let prev_lower = i > 0 && chars.get(i - 1).is_some_and(|p| p.is_lowercase());
83            let next_lower = chars.get(i + 1).is_some_and(|n| n.is_lowercase());
84            if i > 0 && (prev_lower || next_lower) {
85                result.push('_');
86            }
87            result.extend(c.to_lowercase());
88        } else {
89            result.push(c);
90        }
91    }
92    result
93}
94
95/// Simple English pluralization for table-name derivation.
96pub fn pluralize(s: &str) -> String {
97    if s.ends_with("ss")
98        || s.ends_with("sh")
99        || s.ends_with("ch")
100        || s.ends_with('x')
101        || s.ends_with("zz")
102    {
103        format!("{s}es")
104    } else if s.ends_with('z') {
105        format!("{s}zes")
106    } else if s.ends_with('s') {
107        format!("{s}es")
108    } else if let Some(stem) = s.strip_suffix('y') {
109        if !s.ends_with("ay") && !s.ends_with("ey") && !s.ends_with("oy") && !s.ends_with("uy") {
110            format!("{stem}ies")
111        } else {
112            format!("{s}s")
113        }
114    } else {
115        format!("{s}s")
116    }
117}
118
119/// Convert a snake_case string to camelCase.
120pub fn to_camel_case(s: &str) -> String {
121    let mut result = String::new();
122    let mut capitalize_next = false;
123    for c in s.chars() {
124        if c == '_' {
125            capitalize_next = true;
126        } else if capitalize_next {
127            result.extend(c.to_uppercase());
128            capitalize_next = false;
129        } else {
130            result.push(c);
131        }
132    }
133    result
134}
135
136#[cfg(test)]
137#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn test_parse_duration_milliseconds() {
143        assert_eq!(parse_duration("100ms"), Some(Duration::from_millis(100)));
144        assert_eq!(parse_duration("1000ms"), Some(Duration::from_millis(1000)));
145    }
146
147    #[test]
148    fn test_parse_duration_seconds() {
149        assert_eq!(parse_duration("30s"), Some(Duration::from_secs(30)));
150        assert_eq!(parse_duration("60s"), Some(Duration::from_secs(60)));
151    }
152
153    #[test]
154    fn test_parse_duration_minutes() {
155        assert_eq!(parse_duration("5m"), Some(Duration::from_secs(300)));
156        assert_eq!(parse_duration("10m"), Some(Duration::from_secs(600)));
157    }
158
159    #[test]
160    fn test_parse_duration_hours() {
161        assert_eq!(parse_duration("1h"), Some(Duration::from_secs(3600)));
162        assert_eq!(parse_duration("24h"), Some(Duration::from_secs(86400)));
163    }
164
165    #[test]
166    fn test_parse_duration_days() {
167        assert_eq!(parse_duration("1d"), Some(Duration::from_secs(86400)));
168        assert_eq!(parse_duration("7d"), Some(Duration::from_secs(604800)));
169    }
170
171    #[test]
172    fn test_parse_duration_bare_number() {
173        assert_eq!(parse_duration("60"), Some(Duration::from_secs(60)));
174        assert_eq!(parse_duration("3600"), Some(Duration::from_secs(3600)));
175    }
176
177    #[test]
178    fn test_parse_duration_whitespace() {
179        assert_eq!(parse_duration("  30s  "), Some(Duration::from_secs(30)));
180    }
181
182    #[test]
183    fn test_parse_duration_invalid() {
184        assert_eq!(parse_duration("invalid"), None);
185        assert_eq!(parse_duration("abc123"), None);
186        assert_eq!(parse_duration(""), None);
187    }
188
189    #[test]
190    fn test_parse_size_kilobytes() {
191        assert_eq!(parse_size("100kb"), Some(100 * 1024));
192        assert_eq!(parse_size("512KB"), Some(512 * 1024));
193    }
194
195    #[test]
196    fn test_parse_size_megabytes() {
197        assert_eq!(parse_size("20mb"), Some(20 * 1024 * 1024));
198        assert_eq!(parse_size("100MB"), Some(100 * 1024 * 1024));
199    }
200
201    #[test]
202    fn test_parse_size_gigabytes() {
203        assert_eq!(parse_size("1gb"), Some(1024 * 1024 * 1024));
204        assert_eq!(parse_size("2GB"), Some(2 * 1024 * 1024 * 1024));
205    }
206
207    #[test]
208    fn test_parse_size_bytes() {
209        assert_eq!(parse_size("1024b"), Some(1024));
210        assert_eq!(parse_size("0b"), Some(0));
211    }
212
213    #[test]
214    fn test_parse_size_bare_number() {
215        assert_eq!(parse_size("1048576"), Some(1048576));
216    }
217
218    #[test]
219    fn test_parse_size_whitespace() {
220        assert_eq!(parse_size("  20mb  "), Some(20 * 1024 * 1024));
221    }
222
223    #[test]
224    fn test_parse_size_invalid() {
225        assert_eq!(parse_size("invalid"), None);
226        assert_eq!(parse_size("abc123"), None);
227        assert_eq!(parse_size(""), None);
228    }
229
230    #[test]
231    fn test_to_snake_case() {
232        assert_eq!(to_snake_case("GetUser"), "get_user");
233        assert_eq!(to_snake_case("ListAllProjects"), "list_all_projects");
234        assert_eq!(to_snake_case("Simple"), "simple");
235        assert_eq!(to_snake_case("ProjectStatus"), "project_status");
236        assert_eq!(to_snake_case("HTTPServer"), "http_server");
237        assert_eq!(to_snake_case("XMLParser"), "xml_parser");
238        assert_eq!(to_snake_case("listInvoices"), "list_invoices");
239        assert_eq!(to_snake_case("foo2Bar"), "foo2_bar");
240        assert_eq!(to_snake_case("already_snake"), "already_snake");
241    }
242
243    #[test]
244    fn test_to_pascal_case() {
245        assert_eq!(to_pascal_case("get_user"), "GetUser");
246        assert_eq!(to_pascal_case("list_all_projects"), "ListAllProjects");
247        assert_eq!(to_pascal_case("simple"), "Simple");
248    }
249
250    #[test]
251    fn test_pluralize() {
252        assert_eq!(pluralize("user"), "users");
253        assert_eq!(pluralize("bus"), "buses");
254        assert_eq!(pluralize("quiz"), "quizzes");
255        assert_eq!(pluralize("index"), "indexes");
256        assert_eq!(pluralize("match"), "matches");
257        assert_eq!(pluralize("wish"), "wishes");
258        assert_eq!(pluralize("box"), "boxes");
259        assert_eq!(pluralize("class"), "classes");
260        assert_eq!(pluralize("buzz"), "buzzes");
261        assert_eq!(pluralize("policy"), "policies");
262        assert_eq!(pluralize("key"), "keys");
263        assert_eq!(pluralize("day"), "days");
264    }
265
266    #[test]
267    fn test_to_camel_case() {
268        assert_eq!(to_camel_case("get_user"), "getUser");
269        assert_eq!(to_camel_case("list_all_projects"), "listAllProjects");
270        assert_eq!(to_camel_case("simple"), "simple");
271    }
272}