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