Skip to main content

zenkey_fleet/tape/
trigger.rs

1//! Trigger capture (#218; RFC 13 §4.1 version 2, §4.3's pre-roll bullet):
2//! leave a recorder armed with a small pre-roll and a condition, and get a
3//! file only when something happens — with **the thirty seconds before it
4//! fired** in it.
5//!
6//! The pre-roll is the monitor's retained window ([`crate::model::retain`],
7//! #217), re-budgeted to `--pre` — a bounded ring on the ingest path, so
8//! nothing is written while the rules are armed. The condition is the
9//! watchdog's own vocabulary ([`Condition`]), judged tick by tick by a
10//! [`RuleSet`] over **the same event stream the ring is fed from**: one
11//! subscription, one drop ledger. That is the seam's whole reason for
12//! existing — had this run a watchdog beside a recorder, the drops the
13//! judge saw and the drops in the file would have been two different facts
14//! about two different observers, and a `{"dropped": n}` in the file would
15//! say nothing about whether the rule that fired was judged over a clean
16//! window.
17//!
18//! The obstacle nobody else would notice: a pre-roll of a `state` fleet is
19//! uninterpretable. Last-writer-wins keys arrive as deltas with no base —
20//! the value that explains the incident was published an hour before the
21//! window. So the capture carries a **state preamble**: a bounded fan-in
22//! GET on the state-class projection of the watched selectors, taken at
23//! trigger time, written as rows marked `"preamble": true` at `t: 0` ahead
24//! of the pre-roll, each keeping the fetched value's HLC as provenance. The
25//! header states what the preamble is a snapshot *of*
26//! ([`PreambleSemantics`]): the values at the moment the ring began are
27//! not recoverable, and the honest substitute has to be named rather than
28//! implied.
29//!
30//! The file, in order: header → preamble rows → pre-roll rows (their real
31//! `t`, the epoch being the ring's oldest arrival) → the trigger record →
32//! the samples that arrived while the preamble was fetched and the file
33//! opened → the post-roll, drained live through [`record`]. The ring keeps
34//! moving under all of it; the snapshot is taken the instant the rule
35//! fires, before the fetch, because the fetch takes as long as the fleet
36//! takes to answer.
37
38use std::collections::{BTreeSet, HashSet};
39use std::io::Write;
40use std::sync::Arc;
41use std::time::{Duration, Instant};
42
43use crate::bus::monitor::{FleetEvent, SampleView, StreamItem};
44use crate::judge::condition::{Condition, RuleSet, SweepOutcome};
45use crate::model::decode::SchemaStore;
46use crate::model::registry::SliceSet;
47use crate::model::retain::RetentionBudget;
48use crate::report::{
49    CondState, PreRollInfo, PreambleInfo, PreambleSemantics, RecordReport, Transition, ZrecHeader,
50};
51use crate::tape::record::{RecordBounds, ZREC_VERSION, ZrecSink, record};
52use crate::{Error, Result};
53
54/// What a trigger capture watches, judges, and keeps.
55#[derive(Debug, Clone)]
56pub struct TriggerSpec {
57    /// Full wire selectors to retain and record. The rules' own selectors
58    /// are watched too; the header names the union (O5).
59    pub selectors: Vec<String>,
60    /// How far back the retained window reaches: the pre-roll asked for.
61    pub pre: Duration,
62    /// How long to keep recording after the trigger.
63    pub post: Duration,
64    /// The rules; the first transition **to** `firing` on any of them fires
65    /// the capture.
66    pub rules: Vec<Condition>,
67    /// Evaluation cadence — the watchdog's `--every`.
68    pub tick: Duration,
69    /// Per-ask timeout: the roster and doctor sweeps, and the preamble GET.
70    pub timeout: Duration,
71    /// Stop waiting after this long with nothing fired; `None` waits until
72    /// the caller stops the future.
73    pub give_up: Option<Duration>,
74    /// What the preamble is a snapshot of; `None` writes no preamble at all.
75    pub preamble: Option<PreambleSemantics>,
76    /// Stop the post-roll after this many observed samples (the pre-roll
77    /// and the preamble never count towards it).
78    pub max_samples: Option<u64>,
79    /// Replies kept per preamble GET (#339); what the bound cost rides the
80    /// header's `incomplete`.
81    pub max_replies: usize,
82}
83
84/// What a trigger capture reports as it runs — the frontend renders these;
85/// the [`RecordReport`] at the end carries the counts.
86#[derive(Debug, Clone)]
87pub enum TriggerEvent<'a> {
88    /// The subscriptions are declared and the ring is filling: the watch
89    /// set, and the pre-roll it is budgeted to.
90    Armed {
91        watched: &'a [String],
92        pre: Duration,
93    },
94    /// A rule changed state (every genuine change, `firing` or not).
95    Transition(&'a Transition),
96    /// The capture fired on this transition: the ring is snapshotted and
97    /// the preamble is being fetched.
98    Fired(&'a Transition),
99    /// The preamble is written, as the header states it.
100    Preamble(&'a PreambleInfo),
101    /// Post-roll progress: observed samples and drops queued so far.
102    Progress { samples: u64, dropped: u64 },
103    /// Nothing fired within `give_up`; nothing was written.
104    GaveUp { after: Duration },
105}
106
107/// How many samples the post-fire buffer holds while the preamble is
108/// fetched and the file opened — the broadcast's default capacity, four
109/// times over, as the sink's own queue is. Past it the buffer records a
110/// drop where the loss happened rather than growing without bound.
111const FIRE_BUFFER: usize = 4096;
112
113/// The state-class projection of a wire selector under `base`: the
114/// selector narrowed to `state` keys, or `None` when it reaches none.
115///
116/// `v1/<origin>/*/…` and `v1/<origin>/state/…` project onto the `state`
117/// class; `v1/<origin>/**` and `v1/**` widen to `…/state/**` (the class
118/// chunk is positional, RFC 03 §2, so a `**` that spans it is every class
119/// including `state`); a selector naming another class, or too short to
120/// reach a class, reaches no state and yields `None` — which the caller
121/// records under `failed`, because "no preamble for this watch" is a fact
122/// about the watch, not silence. A selector under another base is another
123/// deployment's and yields `None` too.
124pub fn state_projection(base: &str, selector: &str) -> Option<String> {
125    use zenkey::grammar::{CLASS_STATE, VERSION_CHUNK};
126    let rel = zenkey::grammar::strip_base(base, selector)?;
127    let chunks: Vec<&str> = rel.split('/').collect();
128    let projected: Vec<String> = match chunks.as_slice() {
129        // `**` alone, or `v1/**`: every origin's every class.
130        ["**"] | [VERSION_CHUNK, "**"] => {
131            vec![
132                VERSION_CHUNK.into(),
133                "*".into(),
134                CLASS_STATE.into(),
135                "**".into(),
136            ]
137        }
138        [VERSION_CHUNK, origin, "**"] => {
139            vec![
140                VERSION_CHUNK.into(),
141                (*origin).into(),
142                CLASS_STATE.into(),
143                "**".into(),
144            ]
145        }
146        [VERSION_CHUNK, origin, class, rest @ ..] if *class == CLASS_STATE || *class == "*" => {
147            let mut v = vec![
148                VERSION_CHUNK.to_string(),
149                (*origin).into(),
150                CLASS_STATE.into(),
151            ];
152            v.extend(rest.iter().map(|c| (*c).to_string()));
153            if rest.is_empty() {
154                v.push("**".into());
155            }
156            v
157        }
158        _ => return None,
159    };
160    Some(zenkey::grammar::with_base(base, projected.join("/")))
161}
162
163/// The watch set: every selector asked for, minus any that another one in
164/// the set already includes.
165///
166/// A union would be wrong here, not merely wasteful. Each watched selector
167/// is its own subscriber feeding one ring, and a sample matching two of
168/// them is delivered twice — so `--on 'silent-for v1/x/state/p/health 30'`
169/// under a `v1/**` watch would put every `health` sample into the file
170/// twice and count it twice for every rate rule. The narrower expression is
171/// dropped; the wider one still reaches every key the rule judges. Order is
172/// first-seen, so the header's coverage statement (O5) reads as the operator
173/// wrote it.
174pub fn watch_cover<'s>(selectors: impl IntoIterator<Item = &'s String>) -> Vec<String> {
175    let mut distinct: Vec<String> = Vec::new();
176    for sel in selectors {
177        if !distinct.contains(sel) {
178            distinct.push(sel.clone());
179        }
180    }
181    let parsed: Vec<Option<zenoh::key_expr::KeyExpr<'static>>> = distinct
182        .iter()
183        .map(|s| zenoh::key_expr::KeyExpr::try_from(s.clone()).ok())
184        .collect();
185    distinct
186        .iter()
187        .enumerate()
188        .filter(|(i, _)| {
189            let Some(mine) = &parsed[*i] else { return true };
190            !parsed
191                .iter()
192                .enumerate()
193                .any(|(j, other)| j != *i && other.as_ref().is_some_and(|o| o.includes(mine)))
194        })
195        .map(|(_, s)| s.clone())
196        .collect()
197}
198
199/// Arm the rules over the retained window and, when one fires, write the
200/// capture: preamble, pre-roll, trigger, post-roll.
201///
202/// `open` is called **only when something fired** — a run that gives up
203/// leaves no file behind, and the report says so (`out: None`,
204/// `trigger: None`; a rule not firing is not a finding). The header names
205/// the watch set and both version-2 blocks; `pre_roll.covered_s` is what
206/// the ring actually held, which is less than `spec.pre` while the ring is
207/// still filling or when its byte budget bit, and both are said (O6).
208///
209/// Every await inside the drain is the watchdog's (#338): the sweep and the
210/// preamble fetch run *beside* the drain, never instead of it, so the drops
211/// in the file are the bus's and not this loop's own.
212pub async fn record_on<W, F>(
213    fleet: &crate::Fleet<'_>,
214    slices: Option<&SliceSet>,
215    store: &SchemaStore,
216    spec: &TriggerSpec,
217    open: impl FnOnce() -> F,
218    mut on_event: impl FnMut(TriggerEvent<'_>),
219) -> Result<RecordReport>
220where
221    W: Write + Send + 'static,
222    F: std::future::Future<Output = Result<W>>,
223{
224    let (session, base) = (fleet.session(), fleet.base());
225    if spec.rules.is_empty() {
226        return Err(Error::unaskable(
227            "--on",
228            "a trigger capture needs at least one rule to fire on",
229        ));
230    }
231
232    // Compiled before the monitor exists, so the `?` has nothing to tear
233    // down (#336).
234    let mut rules = RuleSet::new(&spec.rules, base, slices)?;
235    let watched = watch_cover(spec.selectors.iter().chain(rules.watched()));
236    let (wants_doctor, wants_roster, wants_decode) = (
237        rules.wants_doctor(),
238        rules.wants_roster(),
239        rules.wants_decode(),
240    );
241    if wants_decode {
242        crate::model::decode::prewarm(fleet, store, slices).await;
243    }
244    let _sealed = store.seal();
245
246    // The ring is budgeted to the pre-roll **before** anything is watched:
247    // `set_budget` applies from the next push, and the first push must
248    // already be under the window this capture claims.
249    let monitor = crate::Monitor::start(session, crate::MonitorSpec::default()).await?;
250    monitor.core().set_retention_budget(RetentionBudget {
251        max_age: spec.pre,
252        ..RetentionBudget::default()
253    });
254    let mut events = monitor.events();
255    let monitor = monitor.watching(&watched).await?;
256    let core = Arc::clone(monitor.core());
257    on_event(TriggerEvent::Armed {
258        watched: &watched,
259        pre: spec.pre,
260    });
261
262    let armed = tokio::time::Instant::now();
263    let give_up_at = spec.give_up.map(|d| armed + d);
264    let mut facts_cache = crate::model::facts::FactsCache::default();
265
266    // ── armed: judge tick by tick, write nothing ──────────────────────────
267    let fired: Option<Transition> = 'armed: loop {
268        let deadline = rules.last_eval() + spec.tick;
269        let sweep = async {
270            let doctor = if wants_doctor {
271                Some(
272                    crate::judge::doctor::run_doctor(
273                        fleet,
274                        slices,
275                        &crate::judge::doctor::DoctorSpec {
276                            deep: false,
277                            sample: None,
278                            timeout: spec.timeout,
279                            listen: None,
280                        },
281                    )
282                    .await
283                    .map_err(|e| e.to_string()),
284                )
285            } else {
286                None
287            };
288            let roster = if wants_roster {
289                Some(
290                    crate::bus::roster::roster(fleet, spec.timeout)
291                        .await
292                        .map_err(|e| e.to_string()),
293                )
294            } else {
295                None
296            };
297            if wants_decode {
298                crate::model::decode::prewarm(fleet, store, slices).await;
299            }
300            (doctor, roster)
301        };
302        let mut sweep = std::pin::pin!(sweep);
303        let mut swept = None;
304        let tick_over = tokio::time::sleep_until(deadline);
305        tokio::pin!(tick_over);
306        let give_up = async {
307            match give_up_at {
308                Some(at) => tokio::time::sleep_until(at).await,
309                None => std::future::pending().await,
310            }
311        };
312        tokio::pin!(give_up);
313        let mut closed = false;
314        let mut gave_up = false;
315        while !closed {
316            let item = tokio::select! {
317                item = events.recv() => item,
318                outcome = &mut sweep, if swept.is_none() => {
319                    swept = Some(outcome);
320                    continue;
321                }
322                () = &mut tick_over, if swept.is_some() => break,
323                () = &mut give_up => {
324                    gave_up = true;
325                    break;
326                }
327            };
328            match item {
329                Some(StreamItem::Event(FleetEvent::Sample(s))) => {
330                    let verdict = if rules.wants_verdict(&s) {
331                        Some(
332                            crate::model::decode::decode_sample(
333                                fleet,
334                                store,
335                                slices,
336                                &s.key,
337                                Some(&s.encoding),
338                                &s.payload.to_bytes(),
339                            )
340                            .await
341                            .verdict,
342                        )
343                    } else {
344                        None
345                    };
346                    rules.observe_sample(&s, &mut facts_cache, verdict.as_ref());
347                }
348                Some(StreamItem::Dropped(n)) => rules.observe_drop(n),
349                Some(_) => {}
350                None => closed = true,
351            }
352        }
353        if gave_up {
354            break 'armed None;
355        }
356        if closed {
357            // The stream closed under the rules: nothing can fire on a
358            // stream that is gone, and nothing was written.
359            break 'armed None;
360        }
361        let (doctor_outcome, roster_outcome) = match swept {
362            Some(outcome) => outcome,
363            None => sweep.await,
364        };
365        let now = tokio::time::Instant::now();
366        let at = crate::tape::record::rfc3339_now();
367        let transitions = rules.evaluate(
368            now,
369            &at,
370            SweepOutcome {
371                doctor: doctor_outcome
372                    .as_ref()
373                    .map(|o| o.as_ref().map_err(String::as_str)),
374                roster: roster_outcome
375                    .as_ref()
376                    .map(|o| o.as_ref().map_err(String::as_str)),
377            },
378        );
379        for t in transitions {
380            on_event(TriggerEvent::Transition(&t));
381            if t.to == CondState::Firing {
382                break 'armed Some(t);
383            }
384        }
385    };
386
387    let Some(trigger) = fired else {
388        monitor.shutdown().await?;
389        let after = armed.elapsed();
390        on_event(TriggerEvent::GaveUp { after });
391        return Ok(RecordReport {
392            header: ZrecHeader {
393                zrec: ZREC_VERSION,
394                selectors: watched,
395                base: base.to_string(),
396                captured_at: crate::tape::record::rfc3339_now(),
397                preamble: None,
398                pre_roll: None,
399            },
400            out: None,
401            samples: 0,
402            dropped: 0,
403            duration_ms: u64::try_from(after.as_millis()).unwrap_or(u64::MAX),
404            trigger: None,
405            preamble: None,
406            pre_roll: None,
407            preamble_rows: 0,
408        });
409    };
410
411    // ── fired: the ring is the pre-roll, taken now ────────────────────────
412    on_event(TriggerEvent::Fired(&trigger));
413    let fired_at = Instant::now();
414    let captured_at = crate::tape::record::rfc3339_now();
415    let ring = core.retained();
416    let stats = core.retention();
417    let epoch = ring.first().map_or(fired_at, |v| v.received);
418    let pre_roll = PreRollInfo {
419        asked_s: spec.pre.as_secs_f64(),
420        covered_s: stats.span.as_secs_f64(),
421        watched: watched.clone(),
422        evicted: stats.evicted,
423        expired: stats.expired,
424    };
425
426    // The broadcast runs behind the ring (the ring is on the ingest path),
427    // so the first items drained from here on are samples the ring already
428    // holds. They are the same `Arc`s: the newest broadcast-capacity-worth
429    // of ring pointers is the exact set to skip.
430    // Addresses, not pointers: the set crosses an `.await`, and a raw
431    // pointer would make this future `!Send` for no reason — nothing is
432    // ever dereferenced through it.
433    let already: HashSet<usize> = ring
434        .iter()
435        .rev()
436        .take(crate::MonitorSpec::default().capacity)
437        .map(|v| Arc::as_ptr(v) as usize)
438        .collect();
439    let mut buffered: Vec<StreamItem> = Vec::new();
440    let mut buffer_dropped = 0u64;
441    let drain_into =
442        |item: Option<StreamItem>, buffered: &mut Vec<StreamItem>, dropped: &mut u64| match item {
443            Some(StreamItem::Event(FleetEvent::Sample(s))) => {
444                if already.contains(&(Arc::as_ptr(&s) as usize)) {
445                    return;
446                }
447                if buffered.len() < FIRE_BUFFER {
448                    buffered.push(StreamItem::Event(FleetEvent::Sample(s)));
449                } else {
450                    *dropped += 1;
451                }
452            }
453            Some(StreamItem::Dropped(n)) => buffered.push(StreamItem::Dropped(n)),
454            _ => {}
455        };
456
457    // The preamble fetch runs beside the drain, like a sweep. Nothing about
458    // it stops sampling: the samples that arrive meanwhile are buffered and
459    // land in the file after the trigger record, where they happened.
460    let preamble = async {
461        let semantics = spec.preamble?;
462        let started = Instant::now();
463        let mut selectors = Vec::new();
464        let mut failed = Vec::new();
465        for sel in &watched {
466            match state_projection(base, sel) {
467                Some(p) if !selectors.contains(&p) => selectors.push(p),
468                Some(_) => {}
469                None => failed.push(sel.clone()),
470            }
471        }
472        let opts = crate::GetOpts::new(spec.timeout).max_replies(spec.max_replies);
473        let gets = futures_util::future::join_all(selectors.iter().map(|selector| {
474            let opts = &opts;
475            async move {
476                (
477                    selector.clone(),
478                    crate::bus::query::snapshot_get(session, selector, opts).await,
479                )
480            }
481        }))
482        .await;
483        let mut values = Vec::new();
484        let mut errors = 0u64;
485        for (selector, replies) in gets {
486            match replies {
487                Ok(r) => {
488                    errors += r.errors;
489                    values.extend(r.values);
490                }
491                Err(e) => {
492                    tracing::warn!(selector, error = %e, "preamble GET could not be issued");
493                    failed.push(selector);
494                }
495            }
496        }
497        let (kept, _superseded) = crate::model::snapshot::fold_latest(values);
498        // What the ring cannot tell you, fetched at trigger time: a key the
499        // ring holds already has its story in the pre-roll rows, and a
500        // preamble that repeated it would put a newer value *before* the
501        // deltas that led to it.
502        let in_ring: BTreeSet<&str> = ring.iter().map(|v| v.key.as_str()).collect();
503        let rows: Vec<Arc<SampleView>> = kept
504            .into_values()
505            .filter(|(view, _)| match semantics {
506                PreambleSemantics::AbsentFromWindow => !in_ring.contains(view.key.as_str()),
507                PreambleSemantics::Full => true,
508            })
509            .map(|(view, _)| Arc::new(view))
510            .collect();
511        Some((
512            PreambleInfo {
513                count: rows.len() as u64,
514                collected_over_s: started.elapsed().as_secs_f64(),
515                selectors,
516                semantics,
517                incomplete: errors + opts.elided(),
518                failed,
519            },
520            rows,
521        ))
522    };
523    let mut preamble = std::pin::pin!(preamble);
524    let fetched = loop {
525        tokio::select! {
526            item = events.recv() => drain_into(item, &mut buffered, &mut buffer_dropped),
527            fetched = &mut preamble => break fetched,
528        }
529    };
530    let (preamble_info, preamble_rows) = match fetched {
531        Some((info, rows)) => (Some(info), rows),
532        None => (None, Vec::new()),
533    };
534    if let Some(info) = &preamble_info {
535        on_event(TriggerEvent::Preamble(info));
536    }
537
538    // ── the file ──────────────────────────────────────────────────────────
539    let header = ZrecHeader {
540        zrec: ZREC_VERSION,
541        selectors: watched.clone(),
542        base: base.to_string(),
543        captured_at,
544        preamble: preamble_info.clone(),
545        pre_roll: Some(pre_roll.clone()),
546    };
547    // Opened only now: a run that never fires leaves nothing behind. The
548    // open itself is the caller's (it names the path) and async, so a
549    // `create` can go through `tokio::fs` (#332); the broadcast's own
550    // capacity covers its length, and the drain below picks the backlog up.
551    let out = match open().await {
552        Ok(out) => out,
553        Err(e) => {
554            if let Err(teardown) = monitor.shutdown().await {
555                tracing::warn!("after a failed open: {teardown}");
556            }
557            return Err(e);
558        }
559    };
560    let sink = ZrecSink::spawn_at(out, &header, epoch).await?;
561    for row in &preamble_rows {
562        sink.write_preamble(Arc::clone(row)).await?;
563    }
564    for view in ring.iter() {
565        sink.write_sample(Arc::clone(view)).await?;
566    }
567    sink.write_trigger(trigger.clone()).await?;
568    // Whatever the broadcast holds *now* is still pre-open backlog: drain it
569    // without waiting, so the post-roll starts from a current stream.
570    while let Ok(item) = tokio::time::timeout(Duration::ZERO, events.recv()).await {
571        drain_into(item, &mut buffered, &mut buffer_dropped);
572    }
573    for item in buffered.drain(..) {
574        match item {
575            StreamItem::Event(FleetEvent::Sample(s)) => sink.write_sample(s).await?,
576            StreamItem::Dropped(n) => sink.write_dropped(n).await?,
577            StreamItem::Event(_) => {}
578        }
579    }
580    if buffer_dropped > 0 {
581        sink.write_dropped(buffer_dropped).await?;
582    }
583
584    // ── the post-roll, live ───────────────────────────────────────────────
585    let pre_written = sink.counts().samples;
586    let bounds = RecordBounds {
587        max_samples: spec.max_samples.map(|n| n + pre_written),
588        max_duration: Some(spec.post),
589    };
590    let mut last_line = Instant::now();
591    let recorded = record(&mut events, &sink, bounds, |samples, dropped| {
592        if last_line.elapsed() >= Duration::from_secs(1) {
593            last_line = Instant::now();
594            on_event(TriggerEvent::Progress { samples, dropped });
595        }
596    })
597    .await;
598    // Teardown before the report, on every path out.
599    let closed = monitor.shutdown().await;
600    recorded?;
601    let counts = sink.finish().await?;
602    closed?;
603    Ok(RecordReport {
604        header,
605        out: None,
606        samples: counts.samples,
607        dropped: counts.dropped,
608        duration_ms: u64::try_from((stats.span + fired_at.elapsed()).as_millis())
609            .unwrap_or(u64::MAX),
610        trigger: Some(trigger),
611        preamble: preamble_info,
612        pre_roll: Some(pre_roll),
613        preamble_rows: counts.preamble,
614    })
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620
621    /// A rule selector the watch already includes is not declared twice —
622    /// that would deliver every matching sample twice into one ring — and
623    /// a wider rule selector supersedes the narrower watch the same way.
624    #[test]
625    fn the_watch_cover_declares_no_included_selector_twice() {
626        let s = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
627        assert_eq!(
628            watch_cover(&s(&["v1/**", "v1/h-aaaaaaaaaaaa/state/p/health"])),
629            s(&["v1/**"])
630        );
631        assert_eq!(
632            watch_cover(&s(&["v1/h-aaaaaaaaaaaa/state/p/health", "v1/**"])),
633            s(&["v1/**"])
634        );
635        assert_eq!(
636            watch_cover(&s(&["v1/a/state/**", "v1/b/state/**", "v1/a/state/**"])),
637            s(&["v1/a/state/**", "v1/b/state/**"])
638        );
639        // Two spellings of one set: the first stays, the identical second
640        // is a duplicate, and an equal-but-distinct expression that includes
641        // the first is kept (it includes; it is not included).
642        assert_eq!(watch_cover(&s(&["v1/x/**", "v1/x/**"])), s(&["v1/x/**"]));
643    }
644
645    /// The projection narrows to `state` where the class is `state` or
646    /// wildcarded, widens a `**` that spans the class chunk, and says
647    /// "reaches no state" for another class or a selector too short to name
648    /// one — never a guess.
649    #[test]
650    fn the_state_projection_narrows_widens_or_declines() {
651        let p = |s| state_projection("", s);
652        assert_eq!(p("v1/**").as_deref(), Some("v1/*/state/**"));
653        assert_eq!(p("**").as_deref(), Some("v1/*/state/**"));
654        assert_eq!(
655            p("v1/h-aaaaaaaaaaaa/**").as_deref(),
656            Some("v1/h-aaaaaaaaaaaa/state/**")
657        );
658        assert_eq!(
659            p("v1/h-aaaaaaaaaaaa/*/demo/**").as_deref(),
660            Some("v1/h-aaaaaaaaaaaa/state/demo/**")
661        );
662        assert_eq!(
663            p("v1/h-aaaaaaaaaaaa/state/demo/health").as_deref(),
664            Some("v1/h-aaaaaaaaaaaa/state/demo/health")
665        );
666        assert_eq!(
667            p("v1/h-aaaaaaaaaaaa/state").as_deref(),
668            Some("v1/h-aaaaaaaaaaaa/state/**")
669        );
670        assert_eq!(p("v1/h-aaaaaaaaaaaa/telemetry/demo/**"), None);
671        assert_eq!(p("v1/h-aaaaaaaaaaaa"), None);
672        assert_eq!(p("v1"), None);
673        // Under a base, the base rides back out — and another deployment's
674        // selector is not projected at all.
675        assert_eq!(
676            state_projection("acme", "acme/v1/**").as_deref(),
677            Some("acme/v1/*/state/**")
678        );
679        assert_eq!(state_projection("acme", "other/v1/**"), None);
680    }
681}