Skip to main content

shep_core/
values.rs

1//! Config value newtypes: memory sizes and durations
2
3use core::fmt;
4use core::str::FromStr;
5
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7
8// Binary units per the Flockfile grammar `^\d+(G|M|K)?$`: K/M/G are
9// KiB/MiB/GiB, not decimal. Unit definitions, not tuning thresholds.
10const KIB: u64 = 1024;
11const MIB: u64 = 1024 * KIB;
12const GIB: u64 = 1024 * MIB;
13
14/// A memory quantity in bytes, used for memory-limit thresholds
15///
16/// Parses the Flockfile grammar `^\d+(G|M|K)?$` (binary units; plain digits
17/// are bytes). Ordering compares byte counts, so a configured limit compares
18/// directly against a sampled RSS wrapped with [`MemSize::from_bytes`].
19///
20/// # Example
21/// ```
22/// use shep_core::values::MemSize;
23///
24/// let limit: MemSize = "512M".parse()?;
25/// assert_eq!(limit.bytes(), 512 << 20);
26/// assert!("512MB".parse::<MemSize>().is_err()); // strict grammar
27/// # Ok::<(), shep_core::values::ParseMemSizeError>(())
28/// ```
29// wire format: changing this is a breaking change (serialized as its string
30// form inside AppConfig, which travels over the client<->daemon socket)
31#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
32pub struct MemSize(u64);
33
34impl MemSize {
35    /// Wraps a raw byte count, e.g. an RSS sample
36    #[inline]
37    #[must_use]
38    pub const fn from_bytes(bytes: u64) -> Self {
39        Self(bytes)
40    }
41
42    /// Returns the quantity in bytes
43    #[inline]
44    #[must_use]
45    pub const fn bytes(self) -> u64 {
46        self.0
47    }
48}
49
50impl FromStr for MemSize {
51    type Err = ParseMemSizeError;
52
53    /// Parses `^\d+(G|M|K)?$`: binary units, plain digits = bytes
54    ///
55    /// # Errors
56    ///
57    /// - [`ParseMemSizeError::Empty`]: empty input.
58    /// - [`ParseMemSizeError::MissingDigits`]: unit suffix with no digits.
59    /// - [`ParseMemSizeError::InvalidCharacter`]: anything outside ASCII
60    ///   digits plus one trailing `G`/`M`/`K` (lowercase, whitespace,
61    ///   fractions, multi-letter suffixes all land here).
62    /// - [`ParseMemSizeError::Overflow`]: byte count exceeds `u64::MAX`.
63    fn from_str(s: &str) -> Result<Self, Self::Err> {
64        if s.is_empty() {
65            return Err(ParseMemSizeError::Empty);
66        }
67        let (digits, multiplier) = match s.as_bytes()[s.len() - 1] {
68            b'G' => (&s[..s.len() - 1], GIB),
69            b'M' => (&s[..s.len() - 1], MIB),
70            b'K' => (&s[..s.len() - 1], KIB),
71            _ => (s, 1),
72        };
73        if digits.is_empty() {
74            return Err(ParseMemSizeError::MissingDigits);
75        }
76        if !digits.bytes().all(|b| b.is_ascii_digit()) {
77            return Err(ParseMemSizeError::InvalidCharacter);
78        }
79        let value: u64 = digits.parse().map_err(|_| ParseMemSizeError::Overflow)?;
80        value
81            .checked_mul(multiplier)
82            .map(Self)
83            .ok_or(ParseMemSizeError::Overflow)
84    }
85}
86
87/// Formats with the largest binary unit dividing the value exactly;
88/// output always re-parses to the same value
89impl fmt::Display for MemSize {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        match self.0 {
92            0 => f.write_str("0"),
93            b if b % GIB == 0 => write!(f, "{}G", b / GIB),
94            b if b % MIB == 0 => write!(f, "{}M", b / MIB),
95            b if b % KIB == 0 => write!(f, "{}K", b / KIB),
96            b => write!(f, "{b}"),
97        }
98    }
99}
100
101impl Serialize for MemSize {
102    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
103        serializer.collect_str(self)
104    }
105}
106
107impl<'de> Deserialize<'de> for MemSize {
108    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
109        // String, not &str: the toml deserializer cannot always borrow
110        let s = String::deserialize(deserializer)?;
111        s.parse().map_err(serde::de::Error::custom)
112    }
113}
114
115/// Failure to parse a [`MemSize`] from the grammar `^\d+(G|M|K)?$`
116///
117/// `#[non_exhaustive]`: a future grammar revision, such as fractional sizes
118/// (`1.5G`) or a binary-vs-decimal distinction, would want its own variant
119/// rather than folding into [`Self::InvalidCharacter`]'s catch-all, and
120/// shep-core is a published library an out-of-tree matcher should not
121/// break for.
122#[non_exhaustive]
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124pub enum ParseMemSizeError {
125    /// The input string was empty
126    Empty,
127    /// A unit suffix with no digits before it (`"M"`)
128    MissingDigits,
129    /// A character outside ASCII digits plus one optional trailing
130    /// `G`/`M`/`K`: covers lowercase units, whitespace, signs, fractions,
131    /// and multi-letter suffixes such as `"MB"`
132    InvalidCharacter,
133    /// The quantity in bytes does not fit in `u64`
134    Overflow,
135}
136
137impl fmt::Display for ParseMemSizeError {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        f.write_str(match self {
140            Self::Empty => "memory size is empty",
141            Self::MissingDigits => "memory size has a unit suffix but no digits",
142            Self::InvalidCharacter => {
143                "memory size must be ASCII digits with an optional trailing G, M, or K"
144            }
145            Self::Overflow => "memory size in bytes overflows u64",
146        })
147    }
148}
149
150impl core::error::Error for ParseMemSizeError {}
151
152/// String-shaped, matching this type's `Serialize`/`Deserialize`, which go
153/// through `Display`/`FromStr` rather than the wrapped `u64`. A derive here
154/// would emit `{"type":"integer"}` and describe a wire form that does not
155/// exist.
156///
157/// The pattern is `FromStr`'s own grammar, lifted from its doc comment
158/// above. If you change one, change the other: the paired test below is
159/// what catches it.
160#[cfg(feature = "schema")]
161impl schemars::JsonSchema for MemSize {
162    fn schema_name() -> std::borrow::Cow<'static, str> {
163        "MemSize".into()
164    }
165
166    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
167        schemars::json_schema!({
168            "type": "string",
169            "pattern": r"^\d+(G|M|K)?$",
170            "description": "A byte quantity: digits, optionally suffixed G, M or K (binary units).",
171        })
172    }
173}
174
175/// A duration from the Flockfile grammar `^\d+(ms|h|m|s)?$`
176///
177/// Plain digits are milliseconds; `ms`/`s`/`m`/`h` are
178/// milliseconds/seconds/minutes/hours. Used for `min_uptime`,
179/// `kill_timeout`, and the other lifecycle timers.
180///
181/// `ms` is checked before the single-letter suffixes, so `m` still means
182/// minutes and only a trailing `ms` means milliseconds: `5m` and `5ms`
183/// differ by a factor of sixty thousand.
184///
185/// # Example
186/// ```
187/// use shep_core::values::UpDuration;
188///
189/// assert_eq!("30s".parse::<UpDuration>()?.as_millis(), 30_000);
190/// assert_eq!("500ms".parse::<UpDuration>()?.as_millis(), 500);
191/// assert_eq!("5m".parse::<UpDuration>()?.as_millis(), 300_000);
192/// assert!("30S".parse::<UpDuration>().is_err()); // lowercase units only
193/// # Ok::<(), shep_core::values::ParseUpDurationError>(())
194/// ```
195// wire format: changing this is a breaking change (string form in AppConfig)
196#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
197pub struct UpDuration(core::time::Duration);
198
199impl UpDuration {
200    /// Wraps a raw millisecond count
201    #[inline]
202    #[must_use]
203    pub const fn from_millis(ms: u64) -> Self {
204        Self(core::time::Duration::from_millis(ms))
205    }
206
207    /// Returns the wrapped [`core::time::Duration`]
208    #[inline]
209    #[must_use]
210    pub const fn as_duration(self) -> core::time::Duration {
211        self.0
212    }
213
214    /// Returns the duration in whole milliseconds
215    #[inline]
216    #[must_use]
217    pub const fn as_millis(self) -> u64 {
218        // Sound: every constructor bounds millis to u64 (`from_millis`
219        // stores its argument directly; `FromStr` reaches this type only
220        // via a `checked_mul` that already fits in u64). Revisit if a raw
221        // `Duration` constructor is ever added.
222        self.0.as_millis() as u64
223    }
224}
225
226impl FromStr for UpDuration {
227    type Err = ParseUpDurationError;
228
229    /// Parses `^\d+(ms|h|m|s)?$`: plain digits are milliseconds
230    ///
231    /// `ms` is matched before the single-letter suffixes below, so a
232    /// trailing `m` alone still means minutes.
233    ///
234    /// # Errors
235    ///
236    /// - [`ParseUpDurationError::Empty`]: empty input.
237    /// - [`ParseUpDurationError::MissingDigits`]: unit with no digits.
238    /// - [`ParseUpDurationError::InvalidCharacter`]: anything outside ASCII
239    ///   digits plus one trailing lowercase `h`/`m`/`s`/`ms`.
240    /// - [`ParseUpDurationError::Overflow`]: milliseconds overflow `u64`.
241    fn from_str(s: &str) -> Result<Self, Self::Err> {
242        if s.is_empty() {
243            return Err(ParseUpDurationError::Empty);
244        }
245        // `ms` first: it shares its trailing `m` with the minutes suffix, so
246        // checking the single-letter match first would parse "5ms" as "5m"
247        // followed by a stray "s" and reject it, or worse, alias the two
248        // suffixes if that stray byte were ever tolerated.
249        let (digits, ms_per_unit) = if let Some(rest) = s.strip_suffix("ms") {
250            (rest, 1)
251        } else {
252            match s.as_bytes()[s.len() - 1] {
253                b'h' => (&s[..s.len() - 1], 3_600_000),
254                b'm' => (&s[..s.len() - 1], 60_000),
255                b's' => (&s[..s.len() - 1], 1_000),
256                _ => (s, 1),
257            }
258        };
259        if digits.is_empty() {
260            return Err(ParseUpDurationError::MissingDigits);
261        }
262        if !digits.bytes().all(|b| b.is_ascii_digit()) {
263            return Err(ParseUpDurationError::InvalidCharacter);
264        }
265        let value: u64 = digits.parse().map_err(|_| ParseUpDurationError::Overflow)?;
266        value
267            .checked_mul(ms_per_unit)
268            .map(Self::from_millis)
269            .ok_or(ParseUpDurationError::Overflow)
270    }
271}
272
273/// Formats with the largest unit dividing the value exactly (ms as digits)
274impl fmt::Display for UpDuration {
275    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
276        let ms = self.as_millis();
277        match ms {
278            0 => f.write_str("0"),
279            v if v % 3_600_000 == 0 => write!(f, "{}h", v / 3_600_000),
280            v if v % 60_000 == 0 => write!(f, "{}m", v / 60_000),
281            v if v % 1_000 == 0 => write!(f, "{}s", v / 1_000),
282            v => write!(f, "{v}"),
283        }
284    }
285}
286
287impl Serialize for UpDuration {
288    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
289        serializer.collect_str(self)
290    }
291}
292
293impl<'de> Deserialize<'de> for UpDuration {
294    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
295        // String, not &str: the toml deserializer cannot always borrow
296        let s = String::deserialize(deserializer)?;
297        s.parse().map_err(serde::de::Error::custom)
298    }
299}
300
301/// Failure to parse an [`UpDuration`] from the grammar `^\d+(ms|h|m|s)?$`
302///
303/// `#[non_exhaustive]`, for the same reason as [`ParseMemSizeError`]: a
304/// future grammar revision, such as fractional durations or a `d`/`w`
305/// unit, would want its own variant rather than folding into
306/// [`Self::InvalidCharacter`]'s catch-all.
307#[non_exhaustive]
308#[derive(Debug, Clone, Copy, PartialEq, Eq)]
309pub enum ParseUpDurationError {
310    /// The input string was empty
311    Empty,
312    /// A unit suffix with no digits before it (`"s"`)
313    MissingDigits,
314    /// A character outside ASCII digits plus one optional trailing
315    /// lowercase `h`/`m`/`s`/`ms`
316    InvalidCharacter,
317    /// The duration in milliseconds does not fit in `u64`
318    Overflow,
319}
320
321impl fmt::Display for ParseUpDurationError {
322    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
323        f.write_str(match self {
324            Self::Empty => "duration is empty",
325            Self::MissingDigits => "duration has a unit suffix but no digits",
326            Self::InvalidCharacter => {
327                "duration must be ASCII digits with an optional trailing h, m, s, or ms"
328            }
329            Self::Overflow => "duration in milliseconds overflows u64",
330        })
331    }
332}
333
334impl core::error::Error for ParseUpDurationError {}
335
336/// String-shaped, matching this type's `Serialize`/`Deserialize`: see
337/// [`MemSize`]'s own `JsonSchema` impl for the full reasoning, which applies
338/// here unchanged.
339#[cfg(feature = "schema")]
340impl schemars::JsonSchema for UpDuration {
341    fn schema_name() -> std::borrow::Cow<'static, str> {
342        "UpDuration".into()
343    }
344
345    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
346        schemars::json_schema!({
347            "type": "string",
348            "pattern": r"^\d+(ms|h|m|s)?$",
349            "description": "A duration: digits, optionally suffixed ms, h, m or s. Plain digits are milliseconds.",
350        })
351    }
352}
353
354#[cfg(test)]
355mod mem_size_tests {
356    use super::*;
357
358    #[test]
359    fn plain_digits_parse_as_bytes() {
360        assert_eq!("123".parse::<MemSize>().unwrap().bytes(), 123);
361    }
362
363    #[test]
364    fn units_are_binary() {
365        assert_eq!("7K".parse::<MemSize>().unwrap().bytes(), 7 * 1024);
366        assert_eq!("512M".parse::<MemSize>().unwrap().bytes(), 512 << 20);
367        assert_eq!("3G".parse::<MemSize>().unwrap().bytes(), 3 << 30);
368    }
369
370    #[test]
371    fn rejects_spec_violations() {
372        use ParseMemSizeError::*;
373        assert_eq!("".parse::<MemSize>(), Err(Empty));
374        assert_eq!("G".parse::<MemSize>(), Err(MissingDigits));
375        assert_eq!("512m".parse::<MemSize>(), Err(InvalidCharacter)); // lowercase
376        assert_eq!(" 512M".parse::<MemSize>(), Err(InvalidCharacter)); // whitespace
377        assert_eq!("1.5G".parse::<MemSize>(), Err(InvalidCharacter)); // fraction
378        assert_eq!("512MB".parse::<MemSize>(), Err(InvalidCharacter)); // multi-letter
379        assert_eq!("18446744073709551616".parse::<MemSize>(), Err(Overflow));
380        assert_eq!("17179869184G".parse::<MemSize>(), Err(Overflow));
381    }
382
383    #[test]
384    fn display_uses_largest_exact_unit_and_round_trips() {
385        for bytes in [
386            0u64,
387            1,
388            1023,
389            1024,
390            1536,
391            1 << 20,
392            (1 << 30) + 1024,
393            u64::MAX,
394        ] {
395            let size = MemSize::from_bytes(bytes);
396            let reparsed: MemSize = size.to_string().parse().unwrap();
397            assert_eq!(reparsed, size, "display of {bytes} bytes must reparse");
398        }
399        assert_eq!(MemSize::from_bytes(3 << 30).to_string(), "3G");
400        assert_eq!(MemSize::from_bytes(1536).to_string(), "1536");
401    }
402
403    #[test]
404    fn serde_uses_string_form() {
405        let size: MemSize = serde_json::from_str("\"512M\"").unwrap();
406        assert_eq!(size.bytes(), 512 << 20);
407        assert_eq!(serde_json::to_string(&size).unwrap(), "\"512M\"");
408        assert!(serde_json::from_str::<MemSize>("\"512MB\"").is_err());
409    }
410
411    /// The pattern must agree with `FromStr`, not just be self-consistent.
412    /// `512T` and `1P` are in the reject list because a widened suffix set
413    /// is the way this pattern most plausibly drifts.
414    #[cfg(feature = "schema")]
415    #[test]
416    fn the_schema_pattern_agrees_with_from_str() {
417        let schema = serde_json::to_value(schemars::schema_for!(MemSize)).unwrap();
418        let pattern = schema["pattern"].as_str().unwrap();
419        let re = regex::Regex::new(pattern).unwrap();
420        for accepted in ["512M", "1G", "4096", "7K"] {
421            assert!(re.is_match(accepted), "pattern rejects {accepted}");
422            assert!(
423                accepted.parse::<MemSize>().is_ok(),
424                "FromStr rejects {accepted}"
425            );
426        }
427        for rejected in ["512MB", "512m", "1.5G", "", "M", "512T", "1P", "512g"] {
428            assert!(!re.is_match(rejected), "pattern accepts {rejected}");
429            assert!(
430                rejected.parse::<MemSize>().is_err(),
431                "FromStr accepts {rejected}"
432            );
433        }
434    }
435}
436
437#[cfg(test)]
438mod up_duration_tests {
439    use super::*;
440
441    #[test]
442    fn plain_digits_are_milliseconds() {
443        assert_eq!("1600".parse::<UpDuration>().unwrap().as_millis(), 1600);
444    }
445
446    #[test]
447    fn units_seconds_minutes_hours() {
448        assert_eq!("30s".parse::<UpDuration>().unwrap().as_millis(), 30_000);
449        assert_eq!("5m".parse::<UpDuration>().unwrap().as_millis(), 300_000);
450        assert_eq!("2h".parse::<UpDuration>().unwrap().as_millis(), 7_200_000);
451    }
452
453    /// `ms` and `m` share a trailing byte, so a naive last-byte match would
454    /// parse "5ms" as "5m" (a 60,000x error) or reject it. Pinned adjacently
455    /// so a regression shows as a wrong multiplier, not a rejected string.
456    #[test]
457    fn milliseconds_do_not_alias_minutes() {
458        assert_eq!("500ms".parse::<UpDuration>().unwrap().as_millis(), 500);
459        assert_eq!("5ms".parse::<UpDuration>().unwrap().as_millis(), 5);
460        assert_eq!("5m".parse::<UpDuration>().unwrap().as_millis(), 300_000);
461        // A bare trailing `m` at end of input is still minutes.
462        assert_eq!("1m".parse::<UpDuration>().unwrap().as_millis(), 60_000);
463    }
464
465    #[test]
466    fn rejects_spec_violations() {
467        use ParseUpDurationError::*;
468        assert_eq!("".parse::<UpDuration>(), Err(Empty));
469        assert_eq!("s".parse::<UpDuration>(), Err(MissingDigits));
470        assert_eq!("ms".parse::<UpDuration>(), Err(MissingDigits));
471        assert_eq!("30S".parse::<UpDuration>(), Err(InvalidCharacter)); // uppercase
472        assert_eq!("1.5s".parse::<UpDuration>(), Err(InvalidCharacter));
473        assert_eq!("30 s".parse::<UpDuration>(), Err(InvalidCharacter));
474        assert_eq!("30MS".parse::<UpDuration>(), Err(InvalidCharacter)); // uppercase ms
475        // Digit string itself overflows u64 before any unit multiplication.
476        assert_eq!("99999999999999999999h".parse::<UpDuration>(), Err(Overflow));
477        // Digit string fits u64 on its own, but overflows on the ×3_600_000
478        // (hours-to-ms) multiplication.
479        assert_eq!("9999999999999999h".parse::<UpDuration>(), Err(Overflow));
480    }
481
482    #[test]
483    fn display_round_trips() {
484        for ms in [
485            0u64, 1, 999, 1000, 1600, 30_000, 300_000, 7_200_000, 3_601_000,
486        ] {
487            let d = UpDuration::from_millis(ms);
488            assert_eq!(d.to_string().parse::<UpDuration>().unwrap(), d, "{ms}ms");
489        }
490        assert_eq!(UpDuration::from_millis(30_000).to_string(), "30s");
491        assert_eq!(UpDuration::from_millis(1600).to_string(), "1600");
492        assert_eq!(UpDuration::from_millis(7_200_000).to_string(), "2h");
493    }
494
495    #[test]
496    fn serde_uses_string_form() {
497        let d: UpDuration = serde_json::from_str("\"30s\"").unwrap();
498        assert_eq!(d.as_millis(), 30_000);
499        assert_eq!(serde_json::to_string(&d).unwrap(), "\"30s\"");
500    }
501
502    /// Same requirement as [`MemSize`]'s schema test: the pattern must
503    /// agree with `FromStr`, not just be self-consistent.
504    #[cfg(feature = "schema")]
505    #[test]
506    fn the_schema_pattern_agrees_with_from_str() {
507        let schema = serde_json::to_value(schemars::schema_for!(UpDuration)).unwrap();
508        let pattern = schema["pattern"].as_str().unwrap();
509        let re = regex::Regex::new(pattern).unwrap();
510        for accepted in ["1600", "30s", "5m", "2h", "500ms"] {
511            assert!(re.is_match(accepted), "pattern rejects {accepted}");
512            assert!(
513                accepted.parse::<UpDuration>().is_ok(),
514                "FromStr rejects {accepted}"
515            );
516        }
517        for rejected in ["30S", "1.5s", "30 s", "", "s", "30d", "30w", "30MS"] {
518            assert!(!re.is_match(rejected), "pattern accepts {rejected}");
519            assert!(
520                rejected.parse::<UpDuration>().is_err(),
521                "FromStr accepts {rejected}"
522            );
523        }
524    }
525}