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