Skip to main content

ms_convert/
lib.rs

1//! Convert human-readable time strings to milliseconds and vice versa.
2//!
3//! A Rust port of [vercel/ms](https://github.com/vercel/ms). Zero dependencies.
4//!
5//! # Examples
6//!
7//! ```
8//! use ms_convert::{parse, format};
9//!
10//! // Parse time strings to milliseconds
11//! assert_eq!(parse("2d").unwrap(), 172_800_000.0);
12//! assert_eq!(parse("1h").unwrap(), 3_600_000.0);
13//!
14//! // Format milliseconds to human-readable strings
15//! assert_eq!(format(60_000.0, false), "1m");
16//! assert_eq!(format(60_000.0, true), "1 minute");
17//! ```
18
19const MILLISECOND: f64 = 1.0;
20const SECOND: f64 = 1_000.0;
21const MINUTE: f64 = SECOND * 60.0;
22const HOUR: f64 = MINUTE * 60.0;
23const DAY: f64 = HOUR * 24.0;
24const WEEK: f64 = DAY * 7.0;
25const MONTH: f64 = DAY * 30.0;
26const YEAR: f64 = DAY * 365.25;
27
28/// Parses a human-readable time string into milliseconds.
29///
30/// Accepts strings like `"100ms"`, `"1s"`, `"2.5h"`, `"1 day"`, etc.
31/// Whitespace between the number and unit is allowed.
32///
33/// # Examples
34///
35/// ```
36/// use ms_convert::parse;
37///
38/// assert_eq!(parse("1s").unwrap(), 1_000.0);
39/// assert_eq!(parse("1.5h").unwrap(), 5_400_000.0);
40/// assert_eq!(parse("2 days").unwrap(), 172_800_000.0);
41/// assert_eq!(parse("-100ms").unwrap(), -100.0);
42/// ```
43///
44/// # Errors
45///
46/// Returns [`ParseError`] if the input is empty, too long, has an invalid format,
47/// or contains an unknown unit.
48pub fn parse(input: &str) -> Result<f64, ParseError> {
49    // 1. 入力の長さバリデーション (1..=99)
50    // 2. 正規表現 or 手動パースで数値と単位を分離
51    // 3. 単位に応じて乗数を決定 (match 式)
52    // 4. 計算結果を返す
53
54    if input.is_empty() {
55        return Err(ParseError::EmptyInput);
56    }
57
58    let input = input.trim();
59
60    if input.len() > 100 {
61        return Err(ParseError::TooLong);
62    }
63
64    // 数値部分の終端を探す
65    let split = input
66        .find(|c: char| !c.is_ascii_digit() && c != '.' && c != '-')
67        .ok_or(ParseError::InvalidFormat)?;
68
69    let (num_part, unit_part) = input.split_at(split);
70    let num: f64 = num_part.parse().map_err(|_| ParseError::InvalidFormat)?;
71    let unit = unit_part.trim();
72
73    let multiplier = match unit {
74        "ms" | "millisecond" | "milliseconds" => MILLISECOND,
75        "s"| "sec" | "second" | "seconds" => SECOND,
76        "m" | "min" | "minute" | "minutes" => MINUTE,
77        "h" | "hr" | "hour" | "hours" => HOUR,
78        "d" | "day" | "days" => DAY,
79        "w" | "week" | "weeks" => WEEK,
80        "mo" | "month" | "months" => MONTH,
81        "y" | "year" | "years" => YEAR,
82        _ => return Err(ParseError::UnknownUnit(unit.to_string()))
83    };
84
85    Ok(num * multiplier)
86}
87
88/// Formats a millisecond value into a human-readable time string.
89///
90/// When `long` is `false`, returns a short format like `"1s"`, `"2d"`.
91/// When `long` is `true`, returns a long format like `"1 second"`, `"2 days"`.
92///
93/// # Examples
94///
95/// ```
96/// use ms_convert::format;
97///
98/// assert_eq!(format(1_000.0, false), "1s");
99/// assert_eq!(format(1_000.0, true), "1 second");
100/// assert_eq!(format(172_800_000.0, true), "2 days");
101/// ```
102pub fn format(ms: f64, long: bool) -> String {
103    let abs = ms.abs();
104
105    let (val, short, singular, plural) = if abs >= YEAR {
106        (ms / YEAR, "y", "year", "years")
107    } else if abs >= MONTH {
108        (ms / MONTH, "mo", "month", "months")
109    } else if abs >= WEEK {
110        (ms / WEEK, "w", "week", "weeks")
111    } else if abs >= DAY {
112        (ms / DAY, "d", "day", "days")
113    } else if abs >= HOUR {
114        (ms / HOUR, "h", "hour", "hours")
115    } else if abs >= MINUTE {
116        (ms / MINUTE, "m", "minute", "minutes")
117    } else if abs >= SECOND {
118        (ms / SECOND, "s", "second", "seconds")
119    } else {
120        (ms, "ms", "millisecond", "milliseconds")
121    };
122
123    let rounded = val.round() as i64;
124
125    if long {
126        let unit = if rounded.abs() == 1 { singular } else { plural };
127        format!("{rounded} {unit}")
128    } else {
129        format!("{rounded}{short}")
130    }
131}
132
133/// Errors that can occur when parsing a time string.
134#[derive(Debug, PartialEq)]
135pub enum ParseError {
136    EmptyInput,
137    TooLong,
138    InvalidFormat,
139    UnknownUnit(String),
140}
141
142impl std::fmt::Display for ParseError {
143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144        match self {
145            ParseError::EmptyInput => write!(f, "input must not be empty"),
146            ParseError::TooLong => write!(f, "input exceeds 100 characters"),
147            ParseError::InvalidFormat => write!(f, "invalid time format"),
148            ParseError::UnknownUnit(u) => write!(f, "unknown unit: {u}"),
149        }
150    }
151}
152
153impl std::error::Error for ParseError {}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    // --- parse: 各単位 (short) ---
160
161    #[test]
162    fn parse_milliseconds() {
163        assert_eq!(parse("100ms").unwrap(), 100.0);
164    }
165
166    #[test]
167    fn parse_seconds() {
168        assert_eq!(parse("1s").unwrap(), 1_000.0);
169    }
170
171    #[test]
172    fn parse_minutes() {
173        assert_eq!(parse("1m").unwrap(), 60_000.0);
174    }
175
176    #[test]
177    fn parse_hours() {
178        assert_eq!(parse("1h").unwrap(), 3_600_000.0);
179    }
180
181    #[test]
182    fn parse_days() {
183        assert_eq!(parse("2d").unwrap(), 172_800_000.0);
184    }
185
186    #[test]
187    fn parse_weeks() {
188        assert_eq!(parse("3w").unwrap(), 1_814_400_000.0);
189    }
190
191    #[test]
192    fn parse_months() {
193        assert_eq!(parse("1mo").unwrap(), 2_592_000_000.0);
194    }
195
196    #[test]
197    fn parse_years() {
198        assert_eq!(parse("1y").unwrap(), 31_557_600_000.0);
199    }
200
201    // --- parse: 各単位 (long) ---
202
203    #[test]
204    fn parse_long_milliseconds() {
205        assert_eq!(parse("1 millisecond").unwrap(), 1.0);
206        assert_eq!(parse("53 milliseconds").unwrap(), 53.0);
207    }
208
209    #[test]
210    fn parse_long_seconds() {
211        assert_eq!(parse("1 sec").unwrap(), 1_000.0);
212        assert_eq!(parse("1 second").unwrap(), 1_000.0);
213        assert_eq!(parse("2 seconds").unwrap(), 2_000.0);
214    }
215
216    #[test]
217    fn parse_long_minutes() {
218        assert_eq!(parse("1 min").unwrap(), 60_000.0);
219        assert_eq!(parse("1 minute").unwrap(), 60_000.0);
220        assert_eq!(parse("2 minutes").unwrap(), 120_000.0);
221    }
222
223    #[test]
224    fn parse_long_hours() {
225        assert_eq!(parse("1 hr").unwrap(), 3_600_000.0);
226        assert_eq!(parse("1 hour").unwrap(), 3_600_000.0);
227        assert_eq!(parse("2 hours").unwrap(), 7_200_000.0);
228    }
229
230    #[test]
231    fn parse_long_days() {
232        assert_eq!(parse("1 day").unwrap(), 86_400_000.0);
233        assert_eq!(parse("2 days").unwrap(), 172_800_000.0);
234    }
235
236    #[test]
237    fn parse_long_weeks() {
238        assert_eq!(parse("1 week").unwrap(), 604_800_000.0);
239        assert_eq!(parse("2 weeks").unwrap(), 1_209_600_000.0);
240    }
241
242    #[test]
243    fn parse_long_months() {
244        assert_eq!(parse("1 month").unwrap(), 2_592_000_000.0);
245        assert_eq!(parse("2 months").unwrap(), 5_184_000_000.0);
246    }
247
248    #[test]
249    fn parse_long_years() {
250        assert_eq!(parse("1 year").unwrap(), 31_557_600_000.0);
251        assert_eq!(parse("2 years").unwrap(), 63_115_200_000.0);
252    }
253
254    // --- parse: ゼロ値 ---
255
256    #[test]
257    fn parse_zero() {
258        assert_eq!(parse("0ms").unwrap(), 0.0);
259    }
260
261    // --- parse: 小数 ---
262
263    #[test]
264    fn parse_decimal() {
265        assert_eq!(parse("1.5h").unwrap(), 5_400_000.0);
266    }
267
268    #[test]
269    fn parse_leading_dot() {
270        assert_eq!(parse(".5ms").unwrap(), 0.5);
271    }
272
273    #[test]
274    fn parse_negative() {
275        assert_eq!(parse("-3 days").unwrap(), -259_200_000.0);
276        assert_eq!(parse("-100ms").unwrap(), -100.0);
277        assert_eq!(parse("-1.5h").unwrap(), -5_400_000.0);
278    }
279
280    #[test]
281    fn parse_negative_leading_dot() {
282        assert_eq!(parse("-.5h").unwrap(), -1_800_000.0);
283    }
284
285    // --- parse: 空白 ---
286
287    #[test]
288    fn parse_extra_whitespace() {
289        assert_eq!(parse("1   s").unwrap(), 1_000.0);
290    }
291
292    // --- parse: 前後空白 ---
293
294    #[test]
295    fn parse_leading_trailing_whitespace() {
296        assert_eq!(parse(" 1s ").unwrap(), 1_000.0);
297    }
298
299    // --- parse: エラー ---
300
301    #[test]
302    fn parse_empty() {
303        assert_eq!(parse("").unwrap_err(), ParseError::EmptyInput);
304    }
305
306    #[test]
307    fn parse_whitespace_only() {
308        assert_eq!(parse("   ").unwrap_err(), ParseError::InvalidFormat);
309    }
310
311    #[test]
312    fn parse_number_only() {
313        assert_eq!(parse("100").unwrap_err(), ParseError::InvalidFormat);
314    }
315
316    #[test]
317    fn parse_no_unit() {
318        assert_eq!(parse("abc").unwrap_err(), ParseError::InvalidFormat);
319    }
320
321    #[test]
322    fn parse_unknown_unit() {
323        assert_eq!(
324            parse("1xyz").unwrap_err(),
325            ParseError::UnknownUnit("xyz".to_string())
326        );
327    }
328
329    #[test]
330    fn parse_boundary_100_chars() {
331        let input = format!("1{}", " ".repeat(97) + "s");
332        assert_eq!(parse(&input).unwrap(), 1_000.0);
333    }
334
335    #[test]
336    fn parse_too_long() {
337        let long_input = format!("1{}", "a".repeat(101));
338        assert_eq!(parse(&long_input).unwrap_err(), ParseError::TooLong);
339    }
340
341    // --- format: short ---
342
343    #[test]
344    fn format_short_ms() {
345        assert_eq!(format(500.0, false), "500ms");
346    }
347
348    #[test]
349    fn format_short_seconds() {
350        assert_eq!(format(1_000.0, false), "1s");
351    }
352
353    #[test]
354    fn format_short_minutes() {
355        assert_eq!(format(60_000.0, false), "1m");
356    }
357
358    #[test]
359    fn format_short_hours() {
360        assert_eq!(format(3_600_000.0, false), "1h");
361    }
362
363    #[test]
364    fn format_short_days() {
365        assert_eq!(format(86_400_000.0, false), "1d");
366    }
367
368    #[test]
369    fn format_short_weeks() {
370        assert_eq!(format(604_800_000.0, false), "1w");
371    }
372
373    #[test]
374    fn format_short_months() {
375        assert_eq!(format(2_628_000_000.0, false), "1mo");
376    }
377
378    #[test]
379    fn format_short_years() {
380        assert_eq!(format(31_557_600_000.0, false), "1y");
381    }
382
383    #[test]
384    fn format_short_rounding() {
385        assert_eq!(format(234_234_234.0, false), "3d");
386    }
387
388    // --- format: long ---
389
390    #[test]
391    fn format_long_ms() {
392        assert_eq!(format(500.0, true), "500 milliseconds");
393    }
394
395    #[test]
396    fn format_long_negative_ms() {
397        assert_eq!(format(-500.0, true), "-500 milliseconds");
398    }
399
400    #[test]
401    fn format_long_second() {
402        assert_eq!(format(1_000.0, true), "1 second");
403    }
404
405    #[test]
406    fn format_long_seconds() {
407        assert_eq!(format(10_000.0, true), "10 seconds");
408    }
409
410    #[test]
411    fn format_long_minute() {
412        assert_eq!(format(60_000.0, true), "1 minute");
413    }
414
415    #[test]
416    fn format_long_minutes() {
417        assert_eq!(format(120_000.0, true), "2 minutes");
418        assert_eq!(format(600_000.0, true), "10 minutes");
419    }
420
421    #[test]
422    fn format_long_hour() {
423        assert_eq!(format(3_600_000.0, true), "1 hour");
424    }
425
426    #[test]
427    fn format_long_hours() {
428        assert_eq!(format(36_000_000.0, true), "10 hours");
429    }
430
431    #[test]
432    fn format_long_day() {
433        assert_eq!(format(86_400_000.0, true), "1 day");
434    }
435
436    #[test]
437    fn format_long_week() {
438        assert_eq!(format(604_800_000.0, true), "1 week");
439    }
440
441    #[test]
442    fn format_long_month() {
443        assert_eq!(format(2_628_000_000.0, true), "1 month");
444    }
445
446    #[test]
447    fn format_long_year() {
448        assert_eq!(format(31_557_600_000.0, true), "1 year");
449    }
450
451    #[test]
452    fn format_long_rounding() {
453        assert_eq!(format(234_234_234.0, true), "3 days");
454    }
455}