Skip to main content

sozu_command_lib/
response.rs

1use std::{cmp::Ordering, collections::BTreeMap, fmt, net::SocketAddr};
2
3use crate::{
4    proto::command::{
5        AddBackend, FilteredTimeSerie, Header, HeaderPredicate, HstsConfig, LoadBalancingParams,
6        PathRule, PathRuleKind, RequestHttpFrontend, RequestTcpFrontend, RequestUdpFrontend,
7        Response, ResponseContent, ResponseStatus, RulePosition, RunState, WorkerResponse,
8    },
9    state::ClusterId,
10};
11
12impl Response {
13    pub fn new(
14        status: ResponseStatus,
15        message: String,
16        content: Option<ResponseContent>,
17    ) -> Response {
18        Response {
19            status: status as i32,
20            message,
21            content,
22        }
23    }
24}
25
26/// An HTTP or HTTPS frontend, as used *within* Sōzu
27#[derive(Clone, PartialOrd, Ord, PartialEq, Eq, Hash, Serialize, Deserialize)]
28pub struct HttpFrontend {
29    /// Send a 401, DENY, if cluster_id is None
30    pub cluster_id: Option<ClusterId>,
31    pub address: SocketAddr,
32    pub hostname: String,
33    #[serde(default)]
34    #[serde(skip_serializing_if = "is_default_path_rule")]
35    pub path: PathRule,
36    #[serde(default)]
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub method: Option<String>,
39    #[serde(default)]
40    pub position: RulePosition,
41    pub tags: Option<BTreeMap<String, String>>,
42    /// Resolved frontend-level policy carried over from
43    /// [`RequestHttpFrontend`]. The router consults these to build a
44    /// `Route::Frontend(Rc<Frontend>)` when any are non-default,
45    /// otherwise falls back to the legacy `Route::ClusterId` /
46    /// `Route::Deny` shapes.
47    #[serde(default)]
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub redirect: Option<i32>,
50    #[serde(default)]
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub redirect_scheme: Option<i32>,
53    #[serde(default)]
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub redirect_template: Option<String>,
56    #[serde(default)]
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub rewrite_host: Option<String>,
59    #[serde(default)]
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub rewrite_path: Option<String>,
62    #[serde(default)]
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub rewrite_port: Option<u32>,
65    #[serde(default)]
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub required_auth: Option<bool>,
68    #[serde(default)]
69    #[serde(skip_serializing_if = "Vec::is_empty")]
70    pub headers: Vec<Header>,
71    /// Resolved per-frontend HSTS (RFC 6797) policy. `None` means inherit
72    /// the listener default at frontend-add time in the worker.
73    #[serde(default)]
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub hsts: Option<HstsConfig>,
76    /// Header predicates the request must all satisfy for this frontend to
77    /// match (sozu-proxy/sozu#1253). Part of the frontend's identity.
78    #[serde(default)]
79    #[serde(skip_serializing_if = "Vec::is_empty")]
80    pub header_predicates: Vec<HeaderPredicate>,
81}
82
83impl fmt::Debug for HttpFrontend {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        let tags_count = self.tags.as_ref().map(BTreeMap::len);
86        let tags_len = self.tags.as_ref().map(|tags| {
87            tags.iter().fold(0usize, |total, (key, value)| {
88                total.saturating_add(key.len()).saturating_add(value.len())
89            })
90        });
91        let headers_len = self.headers.iter().fold(0usize, |total, header| {
92            total
93                .saturating_add(header.key.len())
94                .saturating_add(header.val.len())
95        });
96
97        f.debug_struct("HttpFrontend")
98            .field("cluster_id_len", &self.cluster_id.as_deref().map(str::len))
99            .field("address", &self.address)
100            .field("hostname_len", &self.hostname.len())
101            .field("path_kind", &self.path.kind)
102            .field("path_len", &self.path.value.len())
103            .field("method_len", &self.method.as_ref().map(String::len))
104            .field("position", &self.position)
105            .field("tags_count", &tags_count)
106            .field("tags_len", &tags_len)
107            .field("redirect", &self.redirect)
108            .field("required_auth", &self.required_auth)
109            .field("redirect_scheme", &self.redirect_scheme)
110            .field(
111                "redirect_template_len",
112                &self.redirect_template.as_ref().map(String::len),
113            )
114            .field(
115                "rewrite_host_len",
116                &self.rewrite_host.as_ref().map(String::len),
117            )
118            .field(
119                "rewrite_path_len",
120                &self.rewrite_path.as_ref().map(String::len),
121            )
122            .field("rewrite_port", &self.rewrite_port)
123            .field("headers_count", &self.headers.len())
124            .field("headers_len", &headers_len)
125            .field("hsts", &self.hsts)
126            .field("header_predicates_count", &self.header_predicates.len())
127            .finish_non_exhaustive()
128    }
129}
130
131impl From<HttpFrontend> for RequestHttpFrontend {
132    fn from(val: HttpFrontend) -> Self {
133        let source_address = val.address;
134        let source_hostname = val.hostname.clone();
135        let request_frontend = RequestHttpFrontend {
136            cluster_id: val.cluster_id.as_deref().map(str::to_owned),
137            address: val.address.into(),
138            hostname: val.hostname,
139            path: val.path,
140            method: val.method,
141            position: val.position.into(),
142            tags: val.tags.unwrap_or_default(),
143            redirect: val.redirect,
144            redirect_scheme: val.redirect_scheme,
145            redirect_template: val.redirect_template,
146            rewrite_host: val.rewrite_host,
147            rewrite_path: val.rewrite_path,
148            rewrite_port: val.rewrite_port,
149            required_auth: val.required_auth,
150            headers: val.headers,
151            hsts: val.hsts,
152            header_predicates: val.header_predicates,
153        };
154
155        // POST: the proto-encoded address decodes back to the source SocketAddr
156        // and the hostname is unchanged — the in-Sōzu → wire conversion must be
157        // routing-preserving (the SocketAddress ⇔ SocketAddr round-trip is
158        // exercised in request.rs; here we tie the frontend identity to it).
159        debug_assert_eq!(
160            SocketAddr::from(request_frontend.address),
161            source_address,
162            "frontend address must round-trip through the proto encoding"
163        );
164        debug_assert_eq!(
165            request_frontend.hostname, source_hostname,
166            "frontend hostname must survive the proto conversion"
167        );
168        request_frontend
169    }
170}
171
172impl From<Backend> for AddBackend {
173    fn from(val: Backend) -> Self {
174        let source_address = val.address;
175        let source_cluster_id = val.cluster_id.clone();
176        let source_backend_id = val.backend_id.clone();
177        let add_backend = AddBackend {
178            cluster_id: val.cluster_id.to_string(),
179            backend_id: val.backend_id,
180            address: val.address.into(),
181            sticky_id: val.sticky_id,
182            load_balancing_parameters: val.load_balancing_parameters,
183            backup: val.backup,
184            unix_path: val.unix_path,
185        };
186
187        // POST: backend identity (cluster + backend id) and the wire address
188        // are preserved — a backend that changed cluster/id/address here would
189        // be registered under the wrong key and never receive (or steal)
190        // traffic.
191        debug_assert_eq!(
192            *add_backend.cluster_id, *source_cluster_id,
193            "backend cluster_id must survive the proto conversion"
194        );
195        debug_assert_eq!(
196            add_backend.backend_id, source_backend_id,
197            "backend_id must survive the proto conversion"
198        );
199        debug_assert_eq!(
200            SocketAddr::from(add_backend.address),
201            source_address,
202            "backend address must round-trip through the proto encoding"
203        );
204        add_backend
205    }
206}
207
208impl PathRule {
209    pub fn prefix<S>(value: S) -> Self
210    where
211        S: ToString,
212    {
213        let rule = Self {
214            kind: PathRuleKind::Prefix.into(),
215            value: value.to_string(),
216        };
217        // POST: the encoded kind decodes back to the Prefix variant — the proto
218        // i32 must round-trip or the router would misclassify the match type.
219        debug_assert_eq!(
220            PathRuleKind::try_from(rule.kind),
221            Ok(PathRuleKind::Prefix),
222            "prefix() must encode a Prefix-kind rule"
223        );
224        rule
225    }
226
227    pub fn regex<S>(value: S) -> Self
228    where
229        S: ToString,
230    {
231        let rule = Self {
232            kind: PathRuleKind::Regex.into(),
233            value: value.to_string(),
234        };
235        debug_assert_eq!(
236            PathRuleKind::try_from(rule.kind),
237            Ok(PathRuleKind::Regex),
238            "regex() must encode a Regex-kind rule"
239        );
240        rule
241    }
242
243    /// A `REGEX_PATH` rule (sozu-proxy/sozu#1909): `value` is matched,
244    /// anchored at both ends, against the request path up to its first `?`,
245    /// so the query string never takes part in the match.
246    pub fn regex_path<S>(value: S) -> Self
247    where
248        S: ToString,
249    {
250        let rule = Self {
251            kind: PathRuleKind::RegexPath.into(),
252            value: value.to_string(),
253        };
254        debug_assert_eq!(
255            PathRuleKind::try_from(rule.kind),
256            Ok(PathRuleKind::RegexPath),
257            "regex_path() must encode a RegexPath-kind rule"
258        );
259        rule
260    }
261
262    pub fn equals<S>(value: S) -> Self
263    where
264        S: ToString,
265    {
266        let rule = Self {
267            kind: PathRuleKind::Equals.into(),
268            value: value.to_string(),
269        };
270        debug_assert_eq!(
271            PathRuleKind::try_from(rule.kind),
272            Ok(PathRuleKind::Equals),
273            "equals() must encode an Equals-kind rule"
274        );
275        rule
276    }
277
278    pub fn from_cli_options(
279        path_prefix: Option<String>,
280        path_regex: Option<String>,
281        path_equals: Option<String>,
282        path_regex_path: Option<String>,
283    ) -> Self {
284        // PRE: prefix takes precedence over regex, which takes precedence over
285        // equals, which takes precedence over the path-only regex. Capture
286        // which arms are populated so the post-condition can assert the
287        // precedence actually fired.
288        let had_prefix = path_prefix.is_some();
289        let had_regex = path_regex.is_some();
290        let had_equals = path_equals.is_some();
291        let had_regex_path = path_regex_path.is_some();
292        let rule = match (path_prefix, path_regex, path_equals, path_regex_path) {
293            (Some(prefix), _, _, _) => PathRule {
294                kind: PathRuleKind::Prefix as i32,
295                value: prefix,
296            },
297            (None, Some(regex), _, _) => PathRule {
298                kind: PathRuleKind::Regex as i32,
299                value: regex,
300            },
301            (None, None, Some(equals), _) => PathRule {
302                kind: PathRuleKind::Equals as i32,
303                value: equals,
304            },
305            (None, None, None, Some(regex_path)) => PathRule {
306                kind: PathRuleKind::RegexPath as i32,
307                value: regex_path,
308            },
309            _ => PathRule::default(),
310        };
311
312        // POST: a present prefix wins outright; absent a prefix, a present
313        // regex wins. The resolved kind must reflect that precedence so two
314        // simultaneously-set CLI flags can never silently pick the wrong rule.
315        debug_assert!(
316            !had_prefix || rule.kind == PathRuleKind::Prefix as i32,
317            "a path prefix must produce a Prefix rule regardless of other flags"
318        );
319        debug_assert!(
320            had_prefix || !had_regex || rule.kind == PathRuleKind::Regex as i32,
321            "absent a prefix, a regex must produce a Regex rule"
322        );
323        debug_assert!(
324            had_prefix
325                || had_regex
326                || had_equals
327                || !had_regex_path
328                || rule.kind == PathRuleKind::RegexPath as i32,
329            "alone, a path-only regex must produce a RegexPath rule"
330        );
331        debug_assert!(
332            had_regex_path || rule.kind != PathRuleKind::RegexPath as i32,
333            "a RegexPath rule can only come from a path-only regex flag"
334        );
335        rule
336    }
337}
338
339pub fn is_default_path_rule(p: &PathRule) -> bool {
340    PathRuleKind::try_from(p.kind) == Ok(PathRuleKind::Prefix) && p.value.is_empty()
341}
342
343impl fmt::Display for PathRule {
344    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
345        match PathRuleKind::try_from(self.kind) {
346            Ok(PathRuleKind::Prefix) => write!(f, "prefix '{}'", self.value),
347            Ok(PathRuleKind::Regex) => write!(f, "regexp '{}'", self.value),
348            Ok(PathRuleKind::Equals) => write!(f, "equals '{}'", self.value),
349            Ok(PathRuleKind::RegexPath) => write!(f, "path-only regexp '{}'", self.value),
350            Err(_) => write!(f, ""),
351        }
352    }
353}
354
355/// A TCP frontend, as used *within* Sōzu
356#[derive(Debug, Clone, PartialOrd, Ord, PartialEq, Eq, Hash, Serialize, Deserialize)]
357pub struct TcpFrontend {
358    pub cluster_id: ClusterId,
359    pub address: SocketAddr,
360    /// custom tags to identify the frontend in the access logs
361    pub tags: BTreeMap<String, String>,
362    /// SNI hostname to match against the TLS ClientHello (exact hostname or a
363    /// single leading `*.` wildcard label). `None` matches regardless of SNI.
364    #[serde(default)]
365    pub sni: Option<String>,
366    /// ALPN protocol names this frontend matches; empty is the catch-all for
367    /// its `sni` on this listener.
368    #[serde(default)]
369    pub alpn: Vec<String>,
370}
371
372impl From<TcpFrontend> for RequestTcpFrontend {
373    fn from(val: TcpFrontend) -> Self {
374        let source_address = val.address;
375        let source_cluster_id = val.cluster_id.clone();
376        let source_sni = val.sni.clone();
377        let source_alpn = val.alpn.clone();
378        let request_frontend = RequestTcpFrontend {
379            cluster_id: val.cluster_id.to_string(),
380            address: val.address.into(),
381            tags: val.tags,
382            sni: val.sni,
383            alpn: val.alpn,
384        };
385
386        // POST: cluster identity and the wire address are preserved across the
387        // proto conversion (same routing guarantee as the HTTP frontend path).
388        debug_assert_eq!(
389            *request_frontend.cluster_id, *source_cluster_id,
390            "TCP frontend cluster_id must survive the proto conversion"
391        );
392        debug_assert_eq!(
393            SocketAddr::from(request_frontend.address),
394            source_address,
395            "TCP frontend address must round-trip through the proto encoding"
396        );
397        // POST: SNI/ALPN routing identity is preserved across the proto
398        // conversion — losing either here would silently misroute or
399        // over-match traffic on this frontend's listener.
400        debug_assert_eq!(
401            request_frontend.sni, source_sni,
402            "TCP frontend sni must survive the proto conversion"
403        );
404        debug_assert_eq!(
405            request_frontend.alpn, source_alpn,
406            "TCP frontend alpn must survive the proto conversion"
407        );
408        request_frontend
409    }
410}
411
412/// A UDP frontend, as used *within* Sōzu
413#[derive(Debug, Clone, PartialOrd, Ord, PartialEq, Eq, Hash, Serialize, Deserialize)]
414pub struct UdpFrontend {
415    pub cluster_id: ClusterId,
416    pub address: SocketAddr,
417    /// custom tags to identify the frontend in the access logs
418    pub tags: BTreeMap<String, String>,
419}
420
421impl From<UdpFrontend> for RequestUdpFrontend {
422    fn from(val: UdpFrontend) -> Self {
423        RequestUdpFrontend {
424            cluster_id: val.cluster_id.to_string(),
425            address: val.address.into(),
426            tags: val.tags,
427        }
428    }
429}
430
431/// A backend, as used *within* Sōzu
432#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
433pub struct Backend {
434    pub cluster_id: ClusterId,
435    pub backend_id: String,
436    pub address: SocketAddr,
437    #[serde(default)]
438    #[serde(skip_serializing_if = "Option::is_none")]
439    pub sticky_id: Option<String>,
440    #[serde(default)]
441    #[serde(skip_serializing_if = "Option::is_none")]
442    pub load_balancing_parameters: Option<LoadBalancingParams>,
443    #[serde(default)]
444    #[serde(skip_serializing_if = "Option::is_none")]
445    pub backup: Option<bool>,
446    /// UNIX domain socket dialed instead of `address`, see
447    /// `AddBackend::unix_path` (`command/src/command.proto`).
448    #[serde(default)]
449    #[serde(skip_serializing_if = "Option::is_none")]
450    pub unix_path: Option<String>,
451}
452
453impl Ord for Backend {
454    fn cmp(&self, o: &Backend) -> Ordering {
455        // INV: Equal can only be returned when every keyed field compares
456        // Equal — the tuple of field orderings is the source of truth, and the
457        // `.then(...)` fold must not collapse two distinct backends to Equal
458        // (which would let one silently evict the other from a BTree key set).
459        // Computed inline (no recursion into `cmp`) so it stays cheap.
460        let fields_all_equal = self.cluster_id == o.cluster_id
461            && self.backend_id == o.backend_id
462            && self.sticky_id == o.sticky_id
463            && self.load_balancing_parameters == o.load_balancing_parameters
464            && self.backup == o.backup
465            && self.unix_path == o.unix_path
466            && self.address == o.address;
467
468        let ordering = self
469            .cluster_id
470            .cmp(&o.cluster_id)
471            .then(self.backend_id.cmp(&o.backend_id))
472            .then(self.sticky_id.cmp(&o.sticky_id))
473            .then(
474                self.load_balancing_parameters
475                    .cmp(&o.load_balancing_parameters),
476            )
477            .then(self.backup.cmp(&o.backup))
478            .then(self.unix_path.cmp(&o.unix_path))
479            .then(socketaddr_cmp(&self.address, &o.address));
480
481        debug_assert_eq!(
482            ordering == Ordering::Equal,
483            fields_all_equal,
484            "Backend::cmp returns Equal iff every keyed field is equal"
485        );
486        ordering
487    }
488}
489
490impl PartialOrd for Backend {
491    fn partial_cmp(&self, other: &Backend) -> Option<Ordering> {
492        Some(self.cmp(other))
493    }
494}
495
496impl Backend {
497    pub fn to_add_backend(self) -> AddBackend {
498        let source_address = self.address;
499        let source_backend_id = self.backend_id.clone();
500        let add_backend = AddBackend {
501            cluster_id: self.cluster_id.to_string(),
502            address: self.address.into(),
503            sticky_id: self.sticky_id,
504            backend_id: self.backend_id,
505            load_balancing_parameters: self.load_balancing_parameters,
506            backup: self.backup,
507            unix_path: self.unix_path,
508        };
509
510        // POST: identity (backend id) and wire address are preserved — same
511        // routing guarantee as the `From<Backend>` path, kept in lockstep.
512        debug_assert_eq!(
513            add_backend.backend_id, source_backend_id,
514            "backend_id must survive to_add_backend"
515        );
516        debug_assert_eq!(
517            SocketAddr::from(add_backend.address),
518            source_address,
519            "backend address must round-trip through to_add_backend"
520        );
521        add_backend
522    }
523}
524
525impl fmt::Display for RunState {
526    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
527        write!(f, "{self:?}")
528    }
529}
530
531pub type MessageId = String;
532
533impl WorkerResponse {
534    pub fn ok<T>(id: T) -> Self
535    where
536        T: ToString,
537    {
538        Self {
539            id: id.to_string(),
540            message: String::new(),
541            status: ResponseStatus::Ok.into(),
542            content: None,
543        }
544    }
545
546    pub fn ok_with_content<T>(id: T, content: ResponseContent) -> Self
547    where
548        T: ToString,
549    {
550        Self {
551            id: id.to_string(),
552            status: ResponseStatus::Ok.into(),
553            message: String::new(),
554            content: Some(content),
555        }
556    }
557
558    pub fn error<T, U>(id: T, error: U) -> Self
559    where
560        T: ToString,
561        U: ToString,
562    {
563        Self {
564            id: id.to_string(),
565            message: error.to_string(),
566            status: ResponseStatus::Failure.into(),
567            content: None,
568        }
569    }
570
571    pub fn processing<T>(id: T) -> Self
572    where
573        T: ToString,
574    {
575        Self {
576            id: id.to_string(),
577            message: String::new(),
578            status: ResponseStatus::Processing.into(),
579            content: None,
580        }
581    }
582
583    pub fn with_status<T>(id: T, status: ResponseStatus) -> Self
584    where
585        T: ToString,
586    {
587        Self {
588            id: id.to_string(),
589            message: String::new(),
590            status: status.into(),
591            content: None,
592        }
593    }
594
595    pub fn is_failure(&self) -> bool {
596        self.status == ResponseStatus::Failure as i32
597    }
598}
599
600impl fmt::Display for WorkerResponse {
601    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
602        write!(f, "{}-{:?}", self.id, self.status)
603    }
604}
605
606impl fmt::Display for FilteredTimeSerie {
607    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
608        // `last_minute` / `last_hour` are prost `repeated uint32`, so they are
609        // `Vec<u32>` and not `[u32; 60]`: six fixed 10-wide windows indexed a
610        // series that holds fewer samples out of range. `chunks(10)` renders
611        // the exact same six windows for a full series and simply stops early
612        // for a short one.
613        writeln!(f, "FilteredTimeSerie {{")?;
614        writeln!(f, "last_second: {},", self.last_second)?;
615        writeln!(f, "last_minute:")?;
616        for window in self.last_minute.chunks(10) {
617            writeln!(f, "{window:?}")?;
618        }
619        writeln!(f, "last_hour:")?;
620        for window in self.last_hour.chunks(10) {
621            writeln!(f, "{window:?}")?;
622        }
623        write!(f, "}}")
624    }
625}
626
627fn socketaddr_cmp(a: &SocketAddr, b: &SocketAddr) -> Ordering {
628    let ordering = a.ip().cmp(&b.ip()).then(a.port().cmp(&b.port()));
629    // INV: two socket addresses compare Equal iff both IP and port match —
630    // the `.then` fold must not declare distinct (ip, port) pairs equal, which
631    // would make two different backends indistinguishable to the BTree key.
632    debug_assert_eq!(
633        ordering == Ordering::Equal,
634        a.ip() == b.ip() && a.port() == b.port(),
635        "socketaddr_cmp is Equal iff ip and port both match"
636    );
637    ordering
638}
639
640#[cfg(test)]
641mod tests {
642    //! `FilteredTimeSerie::last_minute` / `last_hour` are prost
643    //! `repeated uint32` (`command.proto`), i.e. `Vec<u32>` and NOT
644    //! `[u32; 60]` — nothing in the type or the wire format guarantees the 60
645    //! samples the `Display` impl used to slice with six fixed 10-wide windows.
646    //!
647    //! To SEE THESE RED (regression proof): restore the pre-fix body of
648    //! `FilteredTimeSerie`'s [`std::fmt::Display`] impl, the six
649    //! `&self.last_minute[0..10] … [50..60]` / `&self.last_hour[..]` fixed
650    //! windows — the short-series expectation below then panics with
651    //! `range end index 10 out of range for slice of length 3`.
652    use crate::proto::command::{FilteredTimeSerie, PathRule, PathRuleKind};
653
654    /// `--path-regex-path` (sozu-proxy/sozu#1909) builds a `REGEX_PATH` rule
655    /// when it is the only path flag, and ranks below the three older flags.
656    #[test]
657    fn the_path_only_regex_cli_flag_builds_a_regex_path_rule_and_ranks_last() {
658        let some = |v: &str| Some(v.to_owned());
659        assert_eq!(
660            PathRule::from_cli_options(None, None, None, some("^/a$")),
661            PathRule::regex_path("^/a$"),
662        );
663        assert_eq!(
664            PathRule::from_cli_options(None, None, some("/e"), some("^/a$")),
665            PathRule::equals("/e"),
666        );
667        assert_eq!(
668            PathRule::from_cli_options(None, some("/r"), None, some("^/a$")),
669            PathRule::regex("/r"),
670        );
671        assert_eq!(
672            PathRule::from_cli_options(some("/p"), None, None, some("^/a$")),
673            PathRule::prefix("/p"),
674        );
675        assert_eq!(
676            PathRule::regex_path("/x").to_string(),
677            "path-only regexp '/x'",
678            "the frontend listing names the kind",
679        );
680        assert_eq!(
681            PathRuleKind::try_from(PathRule::regex_path("/x").kind),
682            Ok(PathRuleKind::RegexPath)
683        );
684    }
685
686    #[test]
687    fn a_series_shorter_than_sixty_samples_renders_instead_of_panicking() {
688        let serie = FilteredTimeSerie {
689            last_second: 42,
690            last_minute: vec![1, 2, 3],
691            last_hour: vec![],
692        };
693
694        let rendered = serie.to_string();
695
696        assert!(
697            rendered.contains("last_second: 42"),
698            "the rendering must keep its header, got {rendered:?}"
699        );
700        assert!(
701            rendered.contains("[1, 2, 3]"),
702            "a partial minute window must render the samples it has, got {rendered:?}"
703        );
704    }
705
706    #[test]
707    fn a_full_series_still_renders_six_windows_per_serie() {
708        // The layout the six fixed windows produced must not drift: one header
709        // line, one label per serie, six 10-sample windows each, one closing
710        // brace.
711        let serie = FilteredTimeSerie {
712            last_second: 7,
713            last_minute: (0..60).collect(),
714            last_hour: (0..60).collect(),
715        };
716
717        let rendered = serie.to_string();
718        let lines: Vec<&str> = rendered.lines().collect();
719
720        assert_eq!(
721            lines.len(),
722            17,
723            "1 header + 1 label + 6 windows + 1 label + 6 windows + 1 brace, got {rendered:?}"
724        );
725        assert_eq!(lines[2], "last_minute:");
726        assert_eq!(lines[3], "[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]");
727        assert_eq!(lines[8], "[50, 51, 52, 53, 54, 55, 56, 57, 58, 59]");
728        assert_eq!(lines[9], "last_hour:");
729        assert_eq!(lines[16], "}");
730    }
731}