Skip to main content

shep_core/protocol/
request.rs

1//! RPC frames: requests, responses, envelopes, and structured errors
2
3use core::fmt;
4
5use serde::{Deserialize, Deserializer, Serialize};
6
7use crate::config::{AppConfig, DeclaredApp, ResetDepth};
8use crate::status::ProcStatus;
9
10/// Client's opening frame
11///
12/// No `deny_unknown_fields`: refusing an unknown field here would refuse a
13/// newer client before `protocol` is read.
14// wire format: changing this is a breaking change
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct Hello {
17    /// Client crate version (semver string)
18    pub client_version: String,
19    /// [`crate::protocol::PROTOCOL_VERSION`] the client speaks
20    pub protocol: u32,
21    /// The name this client was registered under as a dog, when it is one.
22    ///
23    /// `None` for every other client; a bare `Client` cannot set it. The
24    /// daemon needs it to name a dog it refuses at the handshake, which never
25    /// reaches `Request::DogConfig`. A dog reads its own name from
26    /// `$SHEP_DOG_NAME`.
27    ///
28    /// Absent on the wire rather than `null`, so
29    /// [`crate::protocol::PROTOCOL_VERSION`] does not move for it.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub dog_name: Option<String>,
32}
33
34/// Daemon's handshake answer
35// wire format: changing this is a breaking change
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct HelloAck {
38    /// Daemon crate version
39    pub daemon_version: String,
40    /// Protocol version the daemon speaks
41    pub protocol: u32,
42    /// Daemon pid
43    pub pid: u32,
44}
45
46/// Serializable selector (mirror of [`crate::selector::ProcessSelector`];
47/// regex travels as its source string)
48// wire format: changing this is a breaking change
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
51pub enum SelectorSpec {
52    /// Every sheep
53    All,
54    /// By id
55    Id(u32),
56    /// By exact name
57    Name(String),
58    /// By regex source
59    Regex(String),
60    /// By fold name
61    Fold(String),
62    // Both field names are wire contract, pinned by `request_wire_v6`.
63    /// By app name and instance slot
64    ///
65    /// On the wire: `{"kind":"instance","value":{"name":"web","slot":2}}`.
66    Instance {
67        /// The app name
68        name: String,
69        /// The instance slot, counting from 0
70        slot: u32,
71    },
72}
73
74/// A short marker a dog attaches to a sheep for `shep flock` to paint.
75///
76/// shep stores and prints it, never parses it: `▲ main@a1b2c3` is a
77/// deploy tool's sentence.
78///
79/// The grammar: non-empty once whitespace is discounted, at most
80/// [`Self::MAX_CHARS`] characters, no [`char::is_control`] character,
81/// `\u{1b}` included. Refused, never repaired, and validated here rather
82/// than at the renderer: `shep`'s own `output::width::sanitize_cell` keeps
83/// a well-formed CSI sequence, since shep's colouring is made of them.
84///
85/// [`Self::MAX_CHARS`] counts `char`s, not bytes: a byte cap would refuse a
86/// legitimate CJK smit at roughly a third of its apparent length.
87///
88/// `Debug` is derived: a smit carries no secret, so there is nothing to
89/// redact.
90///
91/// # Example
92/// ```
93/// use shep_core::protocol::Smit;
94///
95/// assert_eq!("▲ main@a1b2c3".parse::<Smit>()?.as_str(), "▲ main@a1b2c3");
96/// assert!("\u{1b}[2Jgone".parse::<Smit>().is_err()); // no escapes
97/// # Ok::<(), shep_core::protocol::SmitError>(())
98/// ```
99// wire format: changing this is a breaking change
100#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
101pub struct Smit(String);
102
103impl Smit {
104    /// The longest a smit may be, in characters.
105    pub const MAX_CHARS: usize = 48;
106
107    /// The marker as text, exactly as its publisher sent it.
108    #[must_use]
109    pub fn as_str(&self) -> &str {
110        &self.0
111    }
112}
113
114impl fmt::Display for Smit {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        f.write_str(&self.0)
117    }
118}
119
120impl core::str::FromStr for Smit {
121    type Err = SmitError;
122
123    /// # Errors
124    /// - [`SmitError::Empty`] if the text is nothing but whitespace.
125    /// - [`SmitError::TooLong`] if it is over [`Self::MAX_CHARS`] characters.
126    /// - [`SmitError::Unprintable`] if it holds a control character.
127    fn from_str(text: &str) -> Result<Self, Self::Err> {
128        if text.trim().is_empty() {
129            return Err(SmitError::Empty);
130        }
131        let chars = text.chars().count();
132        if chars > Self::MAX_CHARS {
133            return Err(SmitError::TooLong { chars });
134        }
135        if text.chars().any(char::is_control) {
136            return Err(SmitError::Unprintable);
137        }
138        Ok(Self(text.to_string()))
139    }
140}
141
142/// Validates on decode: a dog written in another language speaks this wire
143/// directly and never runs [`core::str::FromStr`], so a derived impl would
144/// let `\u{1b}[2J` reach every listing built from a smit.
145impl<'de> Deserialize<'de> for Smit {
146    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
147        // String, not &str: a non-borrowing deserializer cannot always borrow
148        let text = String::deserialize(deserializer)?;
149        text.parse().map_err(serde::de::Error::custom)
150    }
151}
152
153/// Why a string is not a [`Smit`].
154#[non_exhaustive]
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum SmitError {
157    /// Over [`Smit::MAX_CHARS`] characters; carries the count that was sent.
158    TooLong {
159        /// How many characters the string held.
160        chars: usize,
161    },
162    /// A control character, `\u{1b}` included.
163    Unprintable,
164    /// Empty, or nothing but whitespace.
165    Empty,
166}
167
168impl fmt::Display for SmitError {
169    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
170        match self {
171            Self::TooLong { chars } => write!(
172                f,
173                "a smit is at most {} characters; this one is {chars}",
174                Smit::MAX_CHARS
175            ),
176            Self::Unprintable => {
177                f.write_str("a smit may not contain a control character, an escape included")
178            }
179            Self::Empty => f.write_str("a smit may not be empty"),
180        }
181    }
182}
183
184impl core::error::Error for SmitError {}
185
186/// One RPC request
187// wire format: changing existing variants is a breaking change
188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
189#[serde(tag = "kind", rename_all = "snake_case")]
190#[non_exhaustive]
191pub enum Request {
192    /// Liveness check
193    Ping,
194    /// Full flock listing
195    ListFlock,
196    /// Detailed info for matching sheep
197    Describe {
198        /// Which sheep
199        selector: SelectorSpec,
200    },
201    /// Register + start apps
202    Start {
203        /// App configs. The daemon must re-normalize them, since peer input
204        /// is untrusted; failures return [`RpcErrorCode::InvalidConfig`]
205        apps: Vec<AppConfig>,
206    },
207    /// Register apps as flock members without starting any of them
208    ///
209    /// Each app lands `Stopped` and holds no pid; `shep add` is the verb.
210    ///
211    /// Idempotent by name: an app the flock already has is answered as it
212    /// stands, running or not, and nothing about it changes.
213    /// [`Self::ApplyConfig`] merges a template into one the flock already
214    /// has, and `shep add` sends both.
215    ///
216    /// Answers [`Response::Added`].
217    Add {
218        /// App configs, carried exactly as [`Self::Start`] carries them. The
219        /// daemon must re-normalize them, since peer input is untrusted;
220        /// failures return [`RpcErrorCode::InvalidConfig`]
221        apps: Vec<AppConfig>,
222    },
223    /// Ask which of `apps` name a sheep the flock already has under a
224    /// different config
225    ///
226    /// Read-only. [`Self::Start`] on an already-registered name adds
227    /// instances rather than reconciling config.
228    ///
229    /// Answers [`Response::Drifted`] with one [`SheepDrift`] per app that is
230    /// both registered and different. An app the flock does not have is
231    /// absent from the answer, not reported as unchanged.
232    ConfigDrift {
233        /// The configs to compare against, exactly as [`Self::Start`] would
234        /// carry them. The daemon must re-normalize them: peer input is
235        /// untrusted, and an unnormalized config would report every default
236        /// it has not spelled out as a difference. Failures return
237        /// [`RpcErrorCode::InvalidConfig`].
238        apps: Vec<AppConfig>,
239    },
240    /// Merge each declared app into the sheep of the same name, applying
241    /// what can be applied and parking the rest for that sheep's next spawn
242    ///
243    /// Nothing is registered, nothing is pruned and nothing running is
244    /// killed: an app the flock does not have is refused by name, and a
245    /// field the running child was spawned from waits for a `shep reload`.
246    /// Additive by default; `reset` widens it.
247    ///
248    /// Answers [`Response::Applied`] with one [`SheepApplied`] per entry in
249    /// `apps`, in the order given, found or not and changed or not. One
250    /// app's refusal rides in [`SheepApplied::refused`] and does not cost
251    /// the rest of the file its load.
252    ApplyConfig {
253        /// The apps to merge in, each carrying the keys its document
254        /// literally wrote. The daemon must re-normalize the merge result,
255        /// since peer input is untrusted, and refuses the whole request with
256        /// [`RpcErrorCode::InvalidConfig`] when two entries share a name:
257        /// the second would be merged against a store the first has not
258        /// written yet.
259        apps: Vec<DeclaredApp>,
260        /// How much of what the operator has set since a template last
261        /// loaded this request may overwrite. Default [`ResetDepth::None`],
262        /// which overwrites nothing.
263        ///
264        /// Spelled `none`/`file`/`env`/`policy` on the wire.
265        reset: ResetDepth,
266    },
267    /// One sheep's effective config, for a pane that is about to edit it.
268    ///
269    /// `env` comes back emptied and its key names ride separately, so a
270    /// value never crosses the wire. Read-only: nothing about the sheep
271    /// changes.
272    ///
273    /// Answers [`Response::SheepConfig`], or
274    /// [`RpcErrorCode::NotFound`] when no sheep has that name.
275    SheepConfig {
276        /// The sheep's name, not a selector: a pane edits one sheep, for
277        /// the reason [`Self::Scale`] states at length.
278        name: String,
279    },
280    /// Sets, replaces, or with `None` removes one env key on one sheep,
281    /// recorded as an operator override. Never reads it back.
282    ///
283    /// Its own request rather than a [`Self::ApplyConfig`] depth, because
284    /// no depth does this: `ResetDepth::None` appends only, `File` and
285    /// `Policy` leave env alone, and `Env`/`All` replace the whole map with
286    /// the template's. A pane cannot send the whole map, since it is never
287    /// told the values it would have to send back.
288    ///
289    /// The running child holds the env it was spawned from, so the change
290    /// parks for the next spawn exactly as `ApplyConfig` parks a
291    /// respawn-only field, and `shep reload`/`shep restart` promote it.
292    ///
293    /// Answers [`Response::SheepEnvSet`], or
294    /// [`RpcErrorCode::NotFound`] when no sheep has that name.
295    SetSheepEnv {
296        /// The sheep's name, not a selector, for [`Self::SheepConfig`]'s
297        /// reason.
298        name: String,
299        /// The env key.
300        key: String,
301        /// The value, or `None` to remove the key.
302        ///
303        /// [`EnvValue`], not a bare `String`, for the reason that type's
304        /// own doc gives: this is the most secret-dense field on the wire
305        /// and a derived `Debug` on [`Request`] would print it (IR-41).
306        value: Option<EnvValue>,
307    },
308    /// Sets one config field on one sheep, recorded as an operator
309    /// override.
310    ///
311    /// [`Self::SetSheepEnv`]'s twin for everything that is not `env`, and
312    /// it exists for the reason that one does rather than by symmetry.
313    /// [`Self::ApplyConfig`] can move a single field (one [`DeclaredApp`]
314    /// declaring one key, at [`ResetDepth::File`]), but it moves it as a
315    /// template and spends the operator's override for it. That reasoning
316    /// does not hold here: a pane's value is the operator's, and the sheep
317    /// still differs from its file. Routed through `ApplyConfig`, the `*`
318    /// marker would never appear for that edit.
319    ///
320    /// One field, not a map: a pane edits one row at a time, and a request
321    /// that took several would need [`Response::Applied`]'s per-field
322    /// reporting back again for no caller that wants it.
323    ///
324    /// `env` is refused here and goes through [`Self::SetSheepEnv`]. So are
325    /// `name` and `instances`, which are
326    /// [`ApplyGroup::Structural`](crate::config::ApplyGroup::Structural):
327    /// identity and flock shape rather than runtime knobs, and the count
328    /// moves through [`Self::Scale`].
329    ///
330    /// The four-way apply classification governs exactly as it does for a
331    /// load. A `Live` field is in force at the daemon's next decision, a
332    /// `NextSpawn` field reaches the stored spec, and a `NeedsRespawn`
333    /// field parks for `shep reload` to promote.
334    ///
335    /// Answers [`Response::SheepFieldSet`], or
336    /// [`RpcErrorCode::NotFound`] when no sheep has that name.
337    SetSheepField {
338        /// The sheep's name, not a selector, for [`Self::SheepConfig`]'s
339        /// reason.
340        name: String,
341        /// The [`AppConfig`] field to set. A key that type has no such
342        /// field is refused with [`RpcErrorCode::InvalidConfig`] rather
343        /// than ignored.
344        key: String,
345        /// The new value, in the shape that field serializes as. The daemon
346        /// must re-validate the resulting config (peer input is untrusted)
347        /// and refuses with [`RpcErrorCode::InvalidConfig`] when it does not
348        /// deserialize or does not normalize; nothing is written in either
349        /// case.
350        ///
351        /// A bare [`serde_json::Value`] and not a redacting newtype, unlike
352        /// [`Self::SetSheepEnv`]'s [`EnvValue`], and the asymmetry is
353        /// deliberate. `env` is the one field [`AppConfig`]'s own manual
354        /// `Debug` redacts; `cwd`, `script` and `args` are printed in the
355        /// clear by every request that already carries a whole config
356        /// ([`Self::Start`], [`Self::Add`], [`Self::ApplyConfig`]). A
357        /// newtype here would protect one copy of a value this enum prints
358        /// three other ways, which reads as a guarantee the wire does not
359        /// make. Widening that protection is a change to [`AppConfig`]'s
360        /// `Debug`, not to this field.
361        value: serde_json::Value,
362    },
363    /// Replaces one dog's `[<name>]` section in `dogs.toml` and publishes
364    /// `config.dog.<name>` so a running dog re-reads it.
365    ///
366    /// The writing twin of [`Self::DogConfig`], which reads the same
367    /// section.
368    ///
369    /// Answers [`Response::DogConfigSet`].
370    SetDogConfig {
371        /// The dog's name, the config key.
372        name: String,
373        /// The whole section, as TOML text.
374        ///
375        /// [`DogSectionToml`], not a bare `String`, for the reason that
376        /// type's own doc gives: a section can hold a dog's credentials and
377        /// this is what keeps them out of a `{:?}` (IR-41).
378        toml: DogSectionToml,
379    },
380    /// Stop matching sheep (stay registered)
381    Stop {
382        /// Which sheep
383        selector: SelectorSpec,
384    },
385    /// Restart matching sheep
386    Restart {
387        /// Which sheep
388        selector: SelectorSpec,
389    },
390    /// Replace each matching sheep with a fresh instance of the same app, one
391    /// instance of an app at a time, so the app has a window in which it can
392    /// stay reachable across the swap
393    Reload {
394        /// Which sheep. No default: a reload replaces running processes.
395        selector: SelectorSpec,
396    },
397    /// Stop + deregister matching sheep
398    Delete {
399        /// Which sheep
400        selector: SelectorSpec,
401    },
402    /// Set how many instances one app runs (see `shep stock`).
403    ///
404    /// Takes a name where every other verb takes a [`SelectorSpec`]:
405    /// `instances` is a per-app number and slots are allocated against the
406    /// same-name group, so a selector matching two apps could mean four of
407    /// each or four in total.
408    ///
409    /// The count is absolute: two operators sending `+2` against the same
410    /// app would get a number neither asked for.
411    Scale {
412        /// The app's name, exactly as its config spells it. Not a selector: no
413        /// `all`, no regex, no `fold:`.
414        name: String,
415        /// How many instances the app has when this returns. `0` is refused
416        /// with [`RpcErrorCode::InvalidConfig`]: `shep delete` is the verb
417        /// for removing an app.
418        count: u32,
419    },
420    /// Attach a short marker to `sheep` for `shep flock` to paint, or clear
421    /// it with `None`.
422    ///
423    /// By name, not a selector: a smit belongs to a sheep, and every
424    /// instance of that name shows it, one spawned after the paint included.
425    ///
426    /// Held in memory and scoped to the connection that sent it, so a
427    /// publisher republishes rather than publishing on change.
428    SetSmit {
429        /// Which sheep.
430        sheep: String,
431        /// The marker, or `None` to clear it.
432        smit: Option<Smit>,
433    },
434    /// Reopen every matched sheep's log files, for an external rotator that
435    /// has renamed them (`create`-mode rotation)
436    Reopen {
437        /// Which sheep
438        selector: SelectorSpec,
439    },
440    /// Empty every matched sheep's log files: flush what is still pending,
441    /// then truncate the recorded paths
442    Flush {
443        /// Which sheep. No default: this destroys log data.
444        selector: SelectorSpec,
445    },
446    /// Send a named action to every matched sheep over its shepherd channel
447    /// and report what each app says back (see `shep trigger`).
448    Trigger {
449        /// Which sheep. No default, matching every other verb that reaches
450        /// a running process.
451        selector: SelectorSpec,
452        /// The action name. Free-form: the daemon never declares, parses, or
453        /// validates it, and an app that does not recognize the name is
454        /// expected to say so in its own reply.
455        action: String,
456        /// Argument text, passed through to the app verbatim. One opaque
457        /// string, matching the shepherd channel's own `action` message this
458        /// becomes.
459        params: Option<String>,
460    },
461    /// Deliver one signal to every matched sheep's own process, never its
462    /// process group (see `shep signal`).
463    Signal {
464        /// Which sheep. No default, matching every other verb that reaches
465        /// a running process.
466        selector: SelectorSpec,
467        /// The signal's name, as
468        /// [`OperatorSignal`](crate::signals::OperatorSignal) spells it. The
469        /// `SIG` prefix and the case are both optional; a name outside the
470        /// grammar answers [`RpcErrorCode::InvalidConfig`].
471        signal: String,
472    },
473    /// Write one line to every matched sheep's stdin (see `shep whisper`).
474    SendLine {
475        /// Which sheep. No default, matching every other verb that reaches a
476        /// running process.
477        selector: SelectorSpec,
478        /// The line, without its terminator: the shepherd appends exactly
479        /// one `\n` when it writes.
480        ///
481        /// A line containing an embedded newline is refused
482        /// ([`RpcErrorCode::InvalidConfig`]): it would deliver two commands
483        /// where the operator typed one.
484        line: String,
485    },
486    /// Write the muster roll now, bypassing the snapshot writer's debounce
487    SaveRoll,
488    /// Assemble the flock from the muster roll on disk: start every app the
489    /// roll recorded running, leaving every app the flock already has exactly
490    /// as it stands
491    Muster,
492    /// Ask for one dog's `[dog.<name>]` section, as the dog itself parses it
493    DogConfig {
494        /// The dog's name: the config key, not a selector
495        name: String,
496    },
497    /// Start one dog now, marking it as coming from `source`
498    EnableDog {
499        /// The dog's name
500        name: String,
501        /// Where its binary comes from
502        source: DogSource,
503    },
504    /// Stop and deregister one dog
505    ///
506    /// Answers [`Response::Deleted`]: disabling deregisters exactly as
507    /// `Delete` does.
508    DisableDog {
509        /// The dog's name
510        name: String,
511    },
512    /// Ask which dogs this daemon has given up on, and which it is still
513    /// waiting to hear from (`shep daemon reload`).
514    ///
515    /// Read-only, and about this daemon's own handshakes: take the reading
516    /// after a reload, not before one. Never sent to an older daemon, on
517    /// [`Self::HandoverFitness`]'s terms.
518    ///
519    /// Answers [`Response::DogStaleness`].
520    DogStaleness,
521    /// Ask whether this daemon could hand its flock to a successor in place,
522    /// rather than stopping it and starting it again (`shep daemon reload`).
523    ///
524    /// Read-only: the handover itself is triggered by a signal, which reaches
525    /// a daemon that refuses the client at the handshake.
526    ///
527    /// Answers [`Response::HandoverFitness`]. A refusal is a feature the
528    /// running daemon cannot carry, not an error: the caller falls back to a
529    /// stop-and-start and prints the reason. Never sent to an older daemon:
530    /// shep-cli's `commands::daemon` gates it on the crate version the
531    /// handshake reported.
532    HandoverFitness,
533    /// Graceful daemon shutdown
534    KillDaemon,
535    /// Subscribe this connection to bus topics (glob patterns)
536    Subscribe {
537        /// Topic globs, e.g. `process.*`
538        topics: Vec<String>,
539    },
540}
541
542/// Where a dog came from: this binary, or one an operator adopted.
543///
544/// Carried on [`ProcessInfo::dog`], so a listing distinguishes the two
545/// populations without a second request.
546// wire format: changing existing variants is a breaking change
547#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
548#[serde(tag = "kind", rename_all = "snake_case")]
549#[non_exhaustive]
550pub enum DogSource {
551    /// An argv branch of the shep binary itself (`shep dog <name>`).
552    BuiltIn,
553    /// A binary an operator adopted, run at the daemon's own trust level.
554    Adopted {
555        /// The binary's path, exactly as the operator gave it to `adopt`.
556        path: String,
557    },
558}
559
560/// One process the OS reports as a descendant of a sheep.
561///
562/// Not the set of processes that die with the sheep: this is a parent-pid
563/// walk, where the stop ladder acts on the process group. A lamb that forks
564/// and exits leaves children re-parented to init, out of this list and still
565/// in the group; a `setsid()` grandchild stays in the list and leaves the
566/// group.
567///
568/// `name` is the executable's name (`node`, `sh`), never argv, which carries
569/// credentials and would ride into `shep describe --format json`. Build one
570/// with [`Self::new`].
571// wire format: changing this is a breaking change
572#[non_exhaustive]
573#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
574pub struct Lamb {
575    /// The lamb's own pid.
576    pub pid: u32,
577    /// The executable's name, as the OS reports it. Never its command line.
578    pub name: String,
579}
580
581impl Lamb {
582    /// One lamb.
583    #[must_use]
584    pub fn new(pid: u32, name: impl Into<String>) -> Self {
585        Self {
586            pid,
587            name: name.into(),
588        }
589    }
590}
591
592/// Why a sheep's process most recently stopped existing under this daemon.
593///
594/// Behind [`ProcessInfo::last_exit`]'s own `Option`, so `None` there means
595/// never exited. Ordinarily exactly one of `code`/`signal` is `Some`,
596/// mirroring the OS's `WIFEXITED`/`WIFSIGNALED` split; both `None` together
597/// is legal and means this daemon recorded an exit it could not
598/// characterize.
599// wire format: changing this is a breaking change
600#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
601pub struct ExitInfo {
602    /// The process's own exit code, set on a normal exit (`WIFEXITED`).
603    pub code: Option<i32>,
604    /// The raw unix signal number that ended the process, set when it did
605    /// not exit on its own (`WIFSIGNALED`). An operator's own `shep stop`
606    /// counts: the process still stopped by a signal.
607    ///
608    /// Platform-specific, and never rendered as a name here; that is an
609    /// OS-aware layer's job.
610    pub signal: Option<i32>,
611}
612
613/// Snapshot of one sheep for listings and events
614///
615/// Construct one with [`ProcessInfo::builder`]: `#[non_exhaustive]` forbids
616/// a struct literal outside this crate, though not inside it. The fields
617/// stay `pub`.
618// wire format: changing this is a breaking change. No `Eq`: `cpu_percent` is
619// an `f32`. Paths travel as `String`, since serde's `PathBuf` refuses a
620// non-UTF-8 path and would blank a whole `Reply`. Every added field is an
621// `Option`, so a peer built before it sends no key and `None` reads as unknown.
622#[non_exhaustive]
623#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
624pub struct ProcessInfo {
625    /// Stable numeric id
626    pub id: u32,
627    /// Sheep name
628    pub name: String,
629    /// Lifecycle status
630    pub status: ProcStatus,
631    /// OS pid while running
632    pub pid: Option<u32>,
633    /// Restart count since registration
634    pub restarts: u32,
635    /// Milliseconds since last successful start
636    pub uptime_ms: u64,
637    /// Fold membership
638    pub fold: Option<String>,
639    /// Names this sheep waits for at a staged start, from its
640    /// `depends_on`. Empty both when the sheep declares none and when the
641    /// peer daemon predates the field.
642    #[serde(default)]
643    pub depends_on: Vec<String>,
644    /// Resolved stdout log path: the app's explicit
645    /// [`AppConfig::out_file`] when it set one, else the daemon-derived
646    /// default. `None` only when the peer daemon predates this field.
647    pub out_file: Option<String>,
648    /// Resolved stderr log path, resolved exactly as [`Self::out_file`]
649    pub err_file: Option<String>,
650    /// Tree CPU as a percentage of one core, over the window since the
651    /// daemon's last periodic sample. `None` when the sheep is not running,
652    /// when it has been up for less than one sampling window, or when the
653    /// peer daemon predates the field; all three render as unknown, never as
654    /// zero. A value over 100 is a tree using more than one core.
655    pub cpu_percent: Option<f32>,
656    /// Tree resident set size in bytes, current as of the reply. `None`
657    /// under the same three conditions as [`Self::cpu_percent`], minus the
658    /// window one: memory needs no baseline.
659    pub memory_bytes: Option<u64>,
660    /// Set when this entry is a dog, naming where the dog came from;
661    /// `None` for a sheep.
662    ///
663    /// Two cases, not [`Self::cpu_percent`]'s three: "not a dog" is the true
664    /// answer whether the peer predates the field or the entry is a sheep.
665    pub dog: Option<DogSource>,
666    /// The processes the OS reports as descendants of this sheep, or `None`
667    /// when this reply did not walk for them.
668    ///
669    /// `None` covers two cases: this reply is not a `Describe` (only
670    /// `Describe` walks), or the peer daemon predates the field.
671    /// `Some(vec![])` is the third: walked, and this sheep has no children.
672    ///
673    /// Read [`Lamb`]'s own doc before rendering this: the list is not the set
674    /// of processes a stop kills, and any output built from it has to say so.
675    pub lambs: Option<Vec<Lamb>>,
676    /// How this sheep's process most recently stopped existing under this
677    /// daemon. `None` while it has never exited under this daemon, and when
678    /// the peer daemon predates the field.
679    ///
680    /// Sticky across a respawn: it answers why the sheep last stopped, not
681    /// whether it is stopped now, and updates only on the next exit.
682    pub last_exit: Option<ExitInfo>,
683    /// The marker a dog has asked to have painted beside this sheep, or
684    /// `None` when no dog has painted one, which also covers a peer daemon
685    /// that predates the field.
686    ///
687    /// A `String` rather than a [`Smit`]: this is a report, and the
688    /// validation that makes it safe to print happened at the daemon's
689    /// ingress. Every instance of a name shows the same marker, since smits
690    /// are keyed by sheep name.
691    pub smit: Option<String>,
692    /// Which instance slot of its app this sheep occupies, counting from 0.
693    ///
694    /// `None` when the peer daemon predates the field. Not a bare `u32`
695    /// defaulted to 0: an app stocked to four instances would report four
696    /// rows all claiming slot 0.
697    pub instance: Option<u32>,
698    /// Whether this dog has completed a handshake with the shepherd that is
699    /// reporting it, and not been refused since; `None` for a sheep.
700    ///
701    /// `None` on [`Self::dog`]'s two-case terms: a sheep has no connection
702    /// to the shepherd, so "no handshake fact to report" is the true answer
703    /// for a sheep and for a peer that predates the field alike.
704    ///
705    /// `Some(false)` is the one that matters: a dog on a protocol this
706    /// shepherd refuses is alive, which is all [`Self::status`] reports, and
707    /// not doing its job. A fact and not a verdict, though: a dog spawned a
708    /// moment ago has not handshaken yet and is healthy.
709    pub handshook: Option<bool>,
710    /// Whether the reporting shepherd has given up on this dog: restarted it
711    /// once for never answering, watched that not help, and stopped
712    /// restarting it. `None` for a sheep, on [`Self::handshook`]'s terms.
713    ///
714    /// Not derivable from [`Self::handshook`]. `Some(false)` there covers
715    /// both a dog spawned three seconds ago that has not dialled back and one
716    /// this shepherd has permanently stopped restarting; the first needs
717    /// nothing done and the second is an incident.
718    ///
719    /// A fact and not a verdict: it says the shepherd stopped, never why.
720    /// The why is in that dog's own log (`shep bleats <dog>`).
721    pub dog_stale: Option<bool>,
722    /// The [`AppConfig`] field names this sheep's spec differs from a load's
723    /// parked config for, in field-name order. `None` when nothing is parked,
724    /// and when the peer daemon predates the field.
725    ///
726    /// Names only, never values, as [`SheepDrift::fields`] carries them: a
727    /// differing `env` reports `"env"` and stops there. `shep reload`
728    /// promotes a parked config.
729    #[serde(default, skip_serializing_if = "Option::is_none")]
730    pub pending: Option<Vec<String>>,
731    /// The [`AppConfig`] field names an operator has set on this sheep that
732    /// its current Flockfile does not declare, in field-name order. `None`
733    /// when there is nothing to report, and when the peer daemon predates
734    /// the field.
735    ///
736    /// Names only, never values, for [`Self::pending`]'s reason:
737    /// [`crate::overrides::AppOverrides::fields`] can hold an `env` value.
738    #[serde(default, skip_serializing_if = "Option::is_none")]
739    pub overridden: Option<Vec<String>>,
740    /// The sheep's `max_memory` ceiling in bytes, when it has one.
741    ///
742    /// Additive, like [`Self::instance`] and [`Self::handshook`] before it, so
743    /// neither `PROTOCOL_VERSION` nor `SCHEMA_VERSION` moves: an older payload
744    /// decodes with it absent and an older client ignores it. Lookout's
745    /// `MEM/CEIL` gauge is the only reader; `None` draws an all-tail bar
746    /// rather than guessing a denominator.
747    pub max_memory: Option<u64>,
748}
749
750/// Orders one flock listing the way every operator-facing surface presents
751/// one: `(name, instance, id)`.
752///
753/// Name first: an id is assigned at registration and a `delete all` plus a
754/// fresh start renumbers the flock, where a name survives. A name is not a
755/// total order on its own, so the id breaks the tie and stays an addressing
756/// key (`shep stop 11`).
757///
758/// A listing whose rows all carry `None` for the slot collapses to
759/// `(name, id)`, since `None` sorts before every `Some`.
760pub fn sort_flock(listing: &mut [ProcessInfo]) {
761    listing.sort_unstable_by(|a, b| {
762        (a.name.as_str(), a.instance, a.id).cmp(&(b.name.as_str(), b.instance, b.id))
763    });
764}
765
766impl ProcessInfo {
767    /// Starts a builder for one sheep's row.
768    ///
769    /// The three arguments are the fields no row can omit and no reader can
770    /// default.
771    ///
772    /// No `#[must_use]`: [`ProcessInfoBuilder`] carries one, which clippy's
773    /// `double_must_use` lint treats as covering this return too.
774    pub fn builder(id: u32, name: impl Into<String>, status: ProcStatus) -> ProcessInfoBuilder {
775        ProcessInfoBuilder {
776            info: Self {
777                id,
778                name: name.into(),
779                status,
780                pid: None,
781                restarts: 0,
782                uptime_ms: 0,
783                fold: None,
784                depends_on: Vec::new(),
785                out_file: None,
786                err_file: None,
787                cpu_percent: None,
788                memory_bytes: None,
789                dog: None,
790                lambs: None,
791                last_exit: None,
792                smit: None,
793                instance: None,
794                handshook: None,
795                dog_stale: None,
796                pending: None,
797                overridden: None,
798                max_memory: None,
799            },
800        }
801    }
802}
803
804/// Builds a [`ProcessInfo`], which is `#[non_exhaustive]` and so cannot be
805/// written as a struct literal outside this crate.
806///
807/// Every setter takes the field's own type, `Option` included, so a caller
808/// already holding `Option<u32>` writes `.pid(entry.pid())` rather than an
809/// `if let` ladder. A setter is skipped, not passed `None`, when a row has
810/// nothing to say about that field; the skipped defaults are the ones a
811/// not-yet-running sheep has.
812#[derive(Debug, Clone)]
813#[must_use = "a builder that is never `build`-ed produces no ProcessInfo"]
814pub struct ProcessInfoBuilder {
815    info: ProcessInfo,
816}
817
818impl ProcessInfoBuilder {
819    /// Sets the OS pid; `None` while the sheep is not running.
820    pub fn pid(mut self, pid: Option<u32>) -> Self {
821        self.info.pid = pid;
822        self
823    }
824
825    /// Sets the restart count since registration.
826    pub fn restarts(mut self, restarts: u32) -> Self {
827        self.info.restarts = restarts;
828        self
829    }
830
831    /// Sets milliseconds since the last successful start.
832    pub fn uptime_ms(mut self, uptime_ms: u64) -> Self {
833        self.info.uptime_ms = uptime_ms;
834        self
835    }
836
837    /// Sets fold membership.
838    pub fn fold(mut self, fold: Option<String>) -> Self {
839        self.info.fold = fold;
840        self
841    }
842
843    /// Sets the names this sheep waits for at a staged start.
844    pub fn depends_on(mut self, depends_on: Vec<String>) -> Self {
845        self.info.depends_on = depends_on;
846        self
847    }
848
849    /// Sets the resolved stdout log path.
850    pub fn out_file(mut self, out_file: Option<String>) -> Self {
851        self.info.out_file = out_file;
852        self
853    }
854
855    /// Sets the resolved stderr log path.
856    pub fn err_file(mut self, err_file: Option<String>) -> Self {
857        self.info.err_file = err_file;
858        self
859    }
860
861    /// Sets tree CPU as a percentage of one core.
862    pub fn cpu_percent(mut self, cpu_percent: Option<f32>) -> Self {
863        self.info.cpu_percent = cpu_percent;
864        self
865    }
866
867    /// Sets tree resident set size in bytes.
868    pub fn memory_bytes(mut self, memory_bytes: Option<u64>) -> Self {
869        self.info.memory_bytes = memory_bytes;
870        self
871    }
872
873    /// Marks this row a dog and names where the dog came from.
874    pub fn dog(mut self, dog: Option<DogSource>) -> Self {
875        self.info.dog = dog;
876        self
877    }
878
879    /// Sets the sheep's lamb list; `None` when this reply did not walk for one.
880    pub fn lambs(mut self, lambs: Option<Vec<Lamb>>) -> Self {
881        self.info.lambs = lambs;
882        self
883    }
884
885    /// Sets how this sheep's process most recently stopped; `None` while it
886    /// has never exited under this daemon.
887    pub fn last_exit(mut self, last_exit: Option<ExitInfo>) -> Self {
888        self.info.last_exit = last_exit;
889        self
890    }
891
892    /// Sets the marker a dog has painted on this sheep; `None` when none has.
893    pub fn smit(mut self, smit: Option<String>) -> Self {
894        self.info.smit = smit;
895        self
896    }
897
898    /// Sets the instance slot; `None` when the peer daemon predates the field.
899    pub fn instance(mut self, instance: Option<u32>) -> Self {
900        self.info.instance = instance;
901        self
902    }
903
904    /// Sets whether this dog has handshaken with the shepherd; `None` for a
905    /// sheep, which has no handshake to report.
906    pub fn handshook(mut self, handshook: Option<bool>) -> Self {
907        self.info.handshook = handshook;
908        self
909    }
910
911    /// Sets whether the shepherd has given up restarting this dog; `None`
912    /// for a sheep, which is never given up on.
913    pub fn dog_stale(mut self, dog_stale: Option<bool>) -> Self {
914        self.info.dog_stale = dog_stale;
915        self
916    }
917
918    /// Sets the field names a load has parked for this sheep's next spawn;
919    /// `None` when nothing is parked.
920    pub fn pending(mut self, pending: Option<Vec<String>>) -> Self {
921        self.info.pending = pending;
922        self
923    }
924
925    /// Sets the field names an operator has overridden on this sheep;
926    /// `None` when there is nothing to report.
927    pub fn overridden(mut self, overridden: Option<Vec<String>>) -> Self {
928        self.info.overridden = overridden;
929        self
930    }
931
932    /// Sets the sheep's `max_memory` ceiling in bytes; `None` when it has no
933    /// ceiling configured.
934    pub fn max_memory(mut self, max_memory: Option<u64>) -> Self {
935        self.info.max_memory = max_memory;
936        self
937    }
938
939    /// Finishes the row.
940    #[must_use]
941    pub fn build(self) -> ProcessInfo {
942        self.info
943    }
944}
945
946/// What happened when the daemon tried to deliver one sheep's triggered
947/// action.
948// wire format: changing existing variants is a breaking change
949#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
950#[serde(tag = "kind", rename_all = "snake_case")]
951#[non_exhaustive]
952pub enum ActionOutcome {
953    /// The app answered on the shepherd channel.
954    Replied {
955        /// The reply body, exactly as the app sent it.
956        body: String,
957    },
958    /// The sheep had no reachable shepherd channel for the daemon to
959    /// deliver the action over.
960    NoChannel,
961    /// The sheep is a reload drainee, mid-swap and on its way out, so the
962    /// daemon skipped it rather than deliver the action to a process already
963    /// being replaced.
964    Skipped,
965    /// The daemon delivered the action, but no reply arrived before the
966    /// app's configured action timeout elapsed.
967    TimedOut,
968}
969
970/// One matched sheep's row in a `Trigger` reply.
971///
972/// Not a [`ProcessInfo`]: a reply body has nowhere to live on one.
973/// [`Self::outcome`] is per-row, since the selector grammar makes a mixed
974/// flock the normal case.
975// wire format: changing this is a breaking change
976#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
977pub struct ActionReply {
978    /// The sheep's stable id.
979    pub id: u32,
980    /// The sheep's name.
981    pub name: String,
982    /// What happened when the daemon tried to deliver the action.
983    pub outcome: ActionOutcome,
984}
985
986/// What happened when the shepherd tried to deliver one signal.
987// wire format: changing existing variants is a breaking change
988#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
989#[serde(tag = "kind", rename_all = "snake_case")]
990#[non_exhaustive]
991pub enum SignalOutcome {
992    /// The kernel accepted the signal for this sheep's pid.
993    ///
994    /// Says the signal was delivered, not that the app did anything with it.
995    /// A signal the app blocks or ignores is `Delivered` too.
996    Delivered,
997    /// The sheep is registered but has no live process to signal: stopped,
998    /// errored, or waiting out a restart backoff.
999    NotRunning,
1000    /// The kernel refused the delivery; carries its reason (`ESRCH` for a
1001    /// process reaped between the lookup and the syscall, `EPERM` for one this
1002    /// daemon may not signal).
1003    Failed {
1004        /// The refusal, as the OS worded it.
1005        reason: String,
1006    },
1007}
1008
1009/// One matched sheep's row in a `Signal` reply.
1010///
1011/// Per-row like [`ActionReply`]: the selector grammar makes a mixed flock
1012/// the normal case.
1013// wire format: changing this is a breaking change
1014#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1015pub struct SignalReply {
1016    /// The sheep's stable id.
1017    pub id: u32,
1018    /// The sheep's name.
1019    pub name: String,
1020    /// What happened when the shepherd tried to deliver the signal.
1021    pub outcome: SignalOutcome,
1022}
1023
1024/// What happened when the shepherd tried to write one line to a sheep's stdin.
1025// wire format: changing existing variants is a breaking change
1026#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1027#[serde(tag = "kind", rename_all = "snake_case")]
1028#[non_exhaustive]
1029pub enum LineOutcome {
1030    /// The line was written to the pipe and flushed.
1031    ///
1032    /// Says the bytes left the shepherd, not that the app read them. A pipe
1033    /// holds 64 KiB before it blocks.
1034    Sent,
1035    /// The sheep has no stdin pipe: its config does not set `stdin = true`, or
1036    /// it is not running.
1037    ///
1038    /// One outcome for two causes: both answer "there is no pipe here".
1039    NoStdin,
1040    /// The shepherd had a pipe and did not confirm a write to it; carries
1041    /// why.
1042    ///
1043    /// Three shapes reach it: the write failed (the far end is gone), the
1044    /// line found the sheep's queue already full, or the write did not
1045    /// finish inside the shepherd's own bound. The reason names which.
1046    ///
1047    /// A timed-out write is not a promise the line was never written: the
1048    /// bytes may be part-written into a pipe the app is not draining, and
1049    /// land in full the moment it drains. A line still queued behind that one
1050    /// is dropped once its caller gives up, so treat a retry as a second
1051    /// command.
1052    NotWritten {
1053        /// What went wrong, in plain English.
1054        reason: String,
1055    },
1056}
1057
1058/// One matched sheep's row in a `SendLine` reply.
1059///
1060/// Per-row like [`ActionReply`] and [`SignalReply`].
1061// wire format: changing this is a breaking change
1062#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1063pub struct LineReply {
1064    /// The sheep's stable id.
1065    pub id: u32,
1066    /// The sheep's name.
1067    pub name: String,
1068    /// What happened.
1069    pub outcome: LineOutcome,
1070}
1071
1072/// A dog's `[dog.<name>]` config section, carried as TOML text.
1073///
1074/// Travels over the socket rather than the child's environment: a dog's
1075/// section routinely holds webhook credentials, and the socket keeps them
1076/// out of the process table and out of crash dumps. The manual `Debug`
1077/// below prints only a length, since [`Response`] derives `Debug`.
1078///
1079/// [`Self::as_str`] is the only way out: a `Deref<Target = str>` would hand
1080/// the type `ToString` and defeat that `Debug`.
1081///
1082/// `#[serde(transparent)]`: the wire representation is a bare `String`.
1083#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
1084#[serde(transparent)]
1085pub struct DogSectionToml(String);
1086
1087impl DogSectionToml {
1088    /// The TOML text, empty when the file has no such section.
1089    #[must_use]
1090    pub fn as_str(&self) -> &str {
1091        &self.0
1092    }
1093}
1094
1095impl From<String> for DogSectionToml {
1096    fn from(toml: String) -> Self {
1097        Self(toml)
1098    }
1099}
1100
1101/// Prints a length, never the section body. Pinned as an exact string by
1102/// `dog_section_toml_debug_does_not_leak`.
1103impl fmt::Debug for DogSectionToml {
1104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1105        write!(f, "DogSectionToml(<{} bytes>)", self.0.len())
1106    }
1107}
1108
1109/// One environment variable's value, on its way to a sheep.
1110///
1111/// A newtype for one reason, the same one [`DogSectionToml`] exists for: an
1112/// env value is the single most secret-dense thing a client can send this
1113/// daemon (a database URL, an API token, a signing key), and a derived
1114/// `Debug` on [`Request`] would print it in the clear the moment anything
1115/// logs a request. Every other secret-bearing field on this wire is already
1116/// protected by its inner type ([`AppConfig`]'s own manual `Debug` prints
1117/// `env: <N vars>`), and a bare `String` here would have been the first
1118/// field in the enum without that protection.
1119///
1120/// One direction only. Nothing ever sends one back: [`Request::SheepConfig`]
1121/// answers with the env keys and no values at all.
1122///
1123/// [`Self::as_str`] is the only way out, for the reason
1124/// [`DogSectionToml`] gives: a `Deref<Target = str>` would hand the type
1125/// `ToString` too, and `.to_string()` would return the value in the clear,
1126/// defeating the redacted `Debug` below.
1127///
1128/// `#[serde(transparent)]` makes the wire representation identical to a
1129/// bare `String`, so this newtype changes nothing about
1130/// [`crate::protocol::PROTOCOL_VERSION`] or the pinned snapshot fixtures.
1131#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
1132#[serde(transparent)]
1133pub struct EnvValue(String);
1134
1135impl EnvValue {
1136    /// The value.
1137    #[must_use]
1138    pub fn as_str(&self) -> &str {
1139        &self.0
1140    }
1141}
1142
1143impl From<String> for EnvValue {
1144    fn from(value: String) -> Self {
1145        Self(value)
1146    }
1147}
1148
1149/// Debug prints a length and never the value (IR-41); see the type doc for
1150/// why. Exact-string-tested below (`env_value_debug_does_not_leak`) so a
1151/// future `#[derive(Debug)]` fails that test instead of silently reopening
1152/// the leak.
1153impl fmt::Debug for EnvValue {
1154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1155        write!(f, "EnvValue(<{} bytes>)", self.0.len())
1156    }
1157}
1158
1159/// One registered sheep whose stored config differs from a caller's copy:
1160/// the answer [`Request::ConfigDrift`] is asking for
1161///
1162/// Field names only, never their values. This is printed at an operator,
1163/// and [`AppConfig::env`](crate::config::AppConfig::env) carries secrets,
1164/// so a differing `env` reports `"env"` and nothing more. `Debug` is
1165/// derived: there is nothing here to redact.
1166// wire format: changing field names is a breaking change
1167#[non_exhaustive]
1168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1169pub struct SheepDrift {
1170    /// The sheep's name. Both configs share it by construction: it is what
1171    /// matched them to each other.
1172    pub name: String,
1173    /// The [`AppConfig`] fields that differ, in field-name order. Never
1174    /// empty: a sheep with nothing to report is left out of the answer.
1175    pub fields: Vec<String>,
1176}
1177
1178impl SheepDrift {
1179    /// Builds one sheep's report.
1180    #[must_use]
1181    pub fn new(name: impl Into<String>, fields: Vec<String>) -> Self {
1182        Self {
1183            name: name.into(),
1184            fields,
1185        }
1186    }
1187}
1188
1189/// What one app's [`Request::ApplyConfig`] did: the answer a load owes the
1190/// operator who ran it
1191///
1192/// One of these per app the request named, found or not and changed or not.
1193///
1194/// [`Self::applied`] and [`Self::pending`] carry field names only, never
1195/// their values, as [`SheepDrift`] does; the merged config never reaches a
1196/// client. [`Self::refused`] is prose and is scoped out of that rule: it
1197/// quotes values out of the file the caller just sent, never out of the
1198/// flock's stored config. `Debug` is derived on that basis: nothing here
1199/// needs redacting.
1200// wire format: changing field names is a breaking change
1201#[non_exhaustive]
1202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1203pub struct SheepApplied {
1204    /// The sheep's name, exactly as the request spelled it.
1205    pub name: String,
1206    /// Fields now in force, in field-name order. Empty when the load changed
1207    /// nothing the daemon could act on immediately.
1208    pub applied: Vec<String>,
1209    /// Fields the app picks up at its next spawn, in field-name order. Empty
1210    /// when nothing is waiting.
1211    ///
1212    /// `shep reload <name>` promotes them; a client rendering this list says
1213    /// so, since a pending list with no remedy beside it cannot be acted on.
1214    pub pending: Vec<String>,
1215    /// Why some or all of this app's change did not land, in the daemon's own
1216    /// words, or `None` when the whole of it did.
1217    ///
1218    /// Not the same question as the two lists being empty: a refusal raised
1219    /// before anything was touched leaves both empty, and so does a load with
1220    /// nothing to do. It is a sentence rather than a code because the message
1221    /// is what tells them apart.
1222    pub refused: Option<String>,
1223}
1224
1225impl SheepApplied {
1226    /// Builds one app's report.
1227    #[must_use]
1228    pub fn new(
1229        name: impl Into<String>,
1230        applied: Vec<String>,
1231        pending: Vec<String>,
1232        refused: Option<String>,
1233    ) -> Self {
1234        Self {
1235            name: name.into(),
1236            applied,
1237            pending,
1238            refused,
1239        }
1240    }
1241}
1242
1243/// One app a multi-sheep reload could not accept, and why
1244///
1245/// A staged reload asks the supervisor per app, so an app already reloading
1246/// is refused on its own while the rest of the fold goes ahead. One of these
1247/// per refused app rides back in [`Response::Reloading`], which is what lets
1248/// the client name the app and exit non-zero instead of printing a table
1249/// with a row quietly missing from it.
1250///
1251/// [`Self::reason`] is the daemon's own sentence rather than a code, the
1252/// rule [`SheepApplied::refused`] takes and for its reason: the class of
1253/// refusal is not on the wire, and the message is what tells two of them
1254/// apart. `Debug` is derived; a name and a refusal sentence carry no env,
1255/// no path and no argument vector.
1256// wire format: changing field names is a breaking change
1257#[non_exhaustive]
1258#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1259pub struct SheepRefusal {
1260    /// The app's name, as the walk that planned the reload spelled it.
1261    pub name: String,
1262    /// Why the reload was refused, in the daemon's own words.
1263    pub reason: String,
1264}
1265
1266impl SheepRefusal {
1267    /// Builds one app's refusal.
1268    #[must_use]
1269    pub fn new(name: impl Into<String>, reason: impl Into<String>) -> Self {
1270        Self {
1271            name: name.into(),
1272            reason: reason.into(),
1273        }
1274    }
1275}
1276
1277/// One sheep's effective config as a pane sees it: every field but env's
1278/// values, plus which fields an operator has overridden and which are
1279/// waiting on a respawn.
1280///
1281/// The answer to [`Request::SheepConfig`], and the one reply in this module
1282/// that carries a whole [`AppConfig`]. [`SheepApplied`] deliberately carries
1283/// field names alone, and the difference is what each is for: that one is
1284/// printed at an operator who already has the file, this one feeds a pane
1285/// that is about to edit fields it has to be able to show first.
1286// wire format: changing field names is a breaking change
1287//
1288// `#[non_exhaustive]`: shep-core is a published library and a sixth field
1289// would otherwise break an out-of-tree consumer's construction of this with
1290// no version bump to say so (IR-20). [`SheepConfigView::new`] is how the
1291// daemon builds one, and it is what enforces the emptied `env`.
1292#[non_exhaustive]
1293#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
1294pub struct SheepConfigView {
1295    /// The sheep's name.
1296    pub name: String,
1297    /// The effective config with `env` cleared. Every remaining field is
1298    /// operator-supplied policy the pane is about to let them edit, so
1299    /// withholding a value would make the pane unusable while protecting
1300    /// nothing.
1301    pub config: AppConfig,
1302    /// The env keys, so the pane can list them. Never the values.
1303    pub env_keys: Vec<String>,
1304    /// Field names an operator has set that the Flockfile does not declare.
1305    pub overridden: Vec<String>,
1306    /// Field names parked until the next respawn.
1307    pub pending: Vec<String>,
1308}
1309
1310impl SheepConfigView {
1311    /// Builds one, clearing `env` and recording its keys.
1312    ///
1313    /// The clearing happens here rather than at the one call site, so a
1314    /// second caller cannot forget it: this constructor is the only way to
1315    /// build the type outside this crate, since `#[non_exhaustive]` blocks
1316    /// a literal.
1317    #[must_use]
1318    pub fn new(mut config: AppConfig, overridden: Vec<String>, pending: Vec<String>) -> Self {
1319        let env_keys = config.env.keys().cloned().collect();
1320        config.env.clear();
1321        Self {
1322            name: config.name.clone(),
1323            config,
1324            env_keys,
1325            overridden,
1326            pending,
1327        }
1328    }
1329}
1330
1331/// Redacted (IR-41): `config` carries `args` and `cwd`, which routinely hold
1332/// a token or a home directory, and this type is what a `{:?}` on a
1333/// [`Response`] would print. The three lists are counted rather than named
1334/// for the same reason: `env_keys` is a key set, which is itself worth
1335/// keeping out of a log.
1336impl fmt::Debug for SheepConfigView {
1337    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1338        write!(
1339            f,
1340            "SheepConfigView {{ name: {:?}, env_keys: {}, overridden: {}, pending: {} }}",
1341            self.name,
1342            self.env_keys.len(),
1343            self.overridden.len(),
1344            self.pending.len()
1345        )
1346    }
1347}
1348
1349/// One RPC response (pairs with [`Request`] variants)
1350///
1351/// Ten variants carry a bare `Vec<ProcessInfo>`. Do not collapse them into
1352/// one: each names which request it answers, which is what lets a variant
1353/// diverge without a protocol bump. `Reloading` already means an acceptance
1354/// rather than a result, `Scaled` only the survivors of a scale-down, and
1355/// `Mustered` every sheep of every restored app rather than what this call
1356/// started.
1357// wire format: changing existing variants is a breaking change.
1358// `large_enum_variant` allowed, not fixed: clippy's remedy is to box
1359// `DogStarted`'s payload, a source break for every
1360// `Response::DogStarted(info)` in and out of this workspace.
1361#[allow(clippy::large_enum_variant)]
1362#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1363#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
1364#[non_exhaustive]
1365pub enum Response {
1366    /// Answer to `Ping`
1367    Pong,
1368    /// Answer to `ListFlock`
1369    Flock(Vec<ProcessInfo>),
1370    /// Answer to `Describe`
1371    Described(Vec<ProcessInfo>),
1372    /// Answer to `Start`
1373    Started(Vec<ProcessInfo>),
1374    /// Answer to `Add`: one row per app the request named, registered and
1375    /// spawning nothing.
1376    ///
1377    /// A row here can still be `Online`: `Add` is idempotent by name, so the
1378    /// reply describes the membership the request leaves behind.
1379    Added(Vec<ProcessInfo>),
1380    /// Answer to `ConfigDrift`: one entry per app that is registered under a
1381    /// config different from the one asked about, and no entry for anything
1382    /// else. An empty vector means every app asked about either matches or
1383    /// is not registered at all.
1384    Drifted(Vec<SheepDrift>),
1385    /// Answer to `ApplyConfig`: one entry per app the request named, in the
1386    /// order it named them, the refused and the unchanged included.
1387    ///
1388    /// Complete where [`Self::Drifted`] is filtered: an app missing from
1389    /// "what did you do to each of these" looks like one the daemon dropped.
1390    Applied(Vec<SheepApplied>),
1391    /// Answer to `SheepConfig`: one sheep's config with `env` emptied and
1392    /// its keys listed beside it.
1393    ///
1394    /// Boxed, and the only variant here that is. This one carries a whole
1395    /// [`AppConfig`], which is several times the size of anything else in
1396    /// the enum, and a `Response` is inside a `Reply` which is inside a
1397    /// [`ServerFrame`](crate::protocol::ServerFrame): without the box,
1398    /// every frame the daemon sends costs the largest config's worth of
1399    /// stack for a variant almost none of them use.
1400    ///
1401    /// The enum-level `#[allow(clippy::large_enum_variant)]` below does not
1402    /// cover it, and the difference is the point of that allow's own
1403    /// argument: boxing `DogStarted` would be a source break for every
1404    /// `Response::DogStarted(info)` in and out of this workspace, where
1405    /// this variant has never shipped and so breaks nobody.
1406    ///
1407    /// `Box<T>` serializes exactly as `T`, so the wire bytes and the pinned
1408    /// fixtures are untouched.
1409    SheepConfig(Box<SheepConfigView>),
1410    /// Answer to `SetSheepEnv`: the key that was set or removed.
1411    ///
1412    /// Never the value, and never the resulting env map. This reply exists
1413    /// to confirm which key moved, and echoing what was just written back
1414    /// down a socket would undo the whole point of `SheepConfig` withholding
1415    /// it (IR-41).
1416    SheepEnvSet {
1417        /// The sheep.
1418        name: String,
1419        /// The key.
1420        key: String,
1421    },
1422    /// Answer to `SetSheepField`: which field moved, and whether the
1423    /// running child has it.
1424    ///
1425    /// Not [`Self::Applied`]'s three lists; the difference is the
1426    /// request's own shape. `applied`, `pending` and `refused` exist
1427    /// because `ApplyConfig` carries N apps of M fields, so a caller cannot
1428    /// otherwise tell which field went where or that one app of eleven was
1429    /// refused. This request carries one field of one sheep, so `refused`
1430    /// would be a second way to say no beside the `Err` arm (a client
1431    /// checking only the `Err` would silently swallow the other), and the
1432    /// two lists collapse to the one bit that is left.
1433    ///
1434    /// That bit is not redundant with the field's own
1435    /// [`ApplyGroup`](crate::config::ApplyGroup), which the caller already
1436    /// knows. It is the daemon's answer about state a caller cannot see:
1437    /// `autostart` is `NextSpawn` and yet reports as in force, because it
1438    /// is read at muster rather than at a spawn, and a `Live` field whose
1439    /// config subset will not normalize on its own parks instead of
1440    /// applying.
1441    SheepFieldSet {
1442        /// The sheep.
1443        name: String,
1444        /// The field that moved.
1445        key: String,
1446        /// `true` when the running child does not have the value yet and
1447        /// `shep reload <name>` is what promotes it. A client rendering
1448        /// this says so, the same rule [`SheepApplied::pending`] carries.
1449        pending: bool,
1450    },
1451    /// Answer to `SetDogConfig`: the section was written and the topic
1452    /// published.
1453    DogConfigSet {
1454        /// The dog.
1455        name: String,
1456    },
1457    /// Answer to `Stop`
1458    Stopped(Vec<ProcessInfo>),
1459    /// Answer to `Restart`
1460    Restarted(Vec<ProcessInfo>),
1461    /// Answer to `Reload`: acceptances, not results.
1462    ///
1463    /// One instance costs a readiness wait plus a drain, so a clustered app
1464    /// outlasts any deadline a client may ask for. Every row is therefore the
1465    /// sheep as it stood when its own reload was accepted, and the swaps
1466    /// report themselves on the bus (`process.reload`, `process.reloaded`,
1467    /// `process.reload_abandoned`). A matched sheep with nothing to replace
1468    /// is listed as the no-op success it is.
1469    ///
1470    /// **When the reply arrives depends on how many sheep matched.** One
1471    /// sheep is answered as soon as its reload is accepted. Two or more are
1472    /// reloaded in dependency order, and the daemon holds each stage until
1473    /// the swaps of the apps a later stage waits on have landed, so the
1474    /// reply arrives no sooner than the last stage's acceptance and the rows
1475    /// are stitched from one acceptance per stage. A client asking for a
1476    /// budget sizes it for the whole walk, not for one acceptance.
1477    Reloading {
1478        /// The sheep whose reloads were accepted, one row each.
1479        accepted: Vec<ProcessInfo>,
1480        /// The apps the walk could not reload, empty when it reloaded every
1481        /// one it named.
1482        ///
1483        /// Only a walk fills this. A selector matching one app is refused
1484        /// whole, as the `Err` arm, so a client reading a single-target
1485        /// reload never sees a row here.
1486        refused: Vec<SheepRefusal>,
1487    },
1488    /// Answer to `Scale`: the app's instances that will remain, one row each,
1489    /// ordered by [`sort_flock`]. Every row shares one name, so that is slot
1490    /// order with the id breaking a tie.
1491    ///
1492    /// Scaling down, the departing instances are absent even though their
1493    /// kill ladders are still running; they report themselves on the bus as
1494    /// `process.delete`.
1495    Scaled(Vec<ProcessInfo>),
1496    /// Answer to `SetSmit`: every instance of the named sheep, one row each,
1497    /// carrying the smit as it now stands.
1498    SmitPainted(Vec<ProcessInfo>),
1499    /// Answer to `Delete`: ids removed
1500    Deleted(Vec<u32>),
1501    /// Answer to `Reopen`: every matched sheep, running or not. A sheep with
1502    /// no live log pump has nothing to reopen and is reported as a success,
1503    /// so this carries the same matches `Describe` would.
1504    Reopened(Vec<ProcessInfo>),
1505    /// Answer to `Flush`: one row per matched sheep, running or not, exactly
1506    /// as [`Self::Reopened`].
1507    ///
1508    /// One row per sheep, not per file emptied: several sheep can share one
1509    /// log path, and the daemon truncates each distinct path once.
1510    Flushed(Vec<ProcessInfo>),
1511    /// Answer to `Trigger`: one [`ActionReply`] row per matched sheep, rather
1512    /// than a flock listing, since `ProcessInfo` has nowhere to hold a reply
1513    /// body.
1514    Triggered(Vec<ActionReply>),
1515    /// Answer to `Signal`: one [`SignalReply`] row per matched sheep.
1516    ///
1517    /// Not a flock listing: [`ProcessInfo`] has nowhere to hold a per-sheep
1518    /// outcome.
1519    Signalled(Vec<SignalReply>),
1520    /// Answer to `SendLine`: one [`LineReply`] row per matched sheep.
1521    SentLine(Vec<LineReply>),
1522    /// Answer to `SaveRoll`
1523    RollSaved {
1524        /// Absolute path of the roll the daemon wrote
1525        path: String,
1526        /// How many apps that roll records
1527        apps: u32,
1528    },
1529    /// Answer to `Muster`: every sheep of every app the roll restored, not
1530    /// only the ones this call spawned.
1531    ///
1532    /// Assembling a flock that is already assembled starts nothing, so a
1533    /// listing of what this call spawned would be indistinguishable from an
1534    /// empty roll.
1535    Mustered(Vec<ProcessInfo>),
1536    /// Answer to `DogConfig`: the dog's own section, rendered back to TOML.
1537    ///
1538    /// `toml` is [`DogSectionToml`], whose manual `Debug` keeps the webhook
1539    /// credentials this text carries out of a `{:?}`-formatted `Response`.
1540    DogSection {
1541        /// The `[dog.<name>]` table as TOML text, empty when the file has
1542        /// no such section
1543        toml: DogSectionToml,
1544    },
1545    /// Answer to `EnableDog`: the dog as it stands now
1546    DogStarted(ProcessInfo),
1547    /// Answer to `DogStaleness`: this daemon's own handshake record, split
1548    /// into the dogs it has given up on and the dogs it is still waiting on.
1549    ///
1550    /// Two lists because they answer two questions. `stale` is a finding;
1551    /// `pending` is a reason to ask again, since a reading taken now would
1552    /// be a guess about them.
1553    ///
1554    /// Names only: two builds differing only in the protocol they speak
1555    /// report the same crate version.
1556    DogStaleness {
1557        /// Dogs this daemon has refused twice: once on the handshake that
1558        /// bought them a restart from disk, and again after it. It will not
1559        /// restart them a third time.
1560        stale: Vec<String>,
1561        /// Dogs this daemon is still waiting to hear a final answer from: one
1562        /// whose restart is in flight, or one it supervises that has not
1563        /// handshook yet. Neither stale nor known healthy.
1564        pending: Vec<String>,
1565    },
1566    /// Answer to `HandoverFitness`: `None` when the whole flock can be
1567    /// carried across a daemon handover, and otherwise the sentence saying
1568    /// which sheep cannot be and why.
1569    ///
1570    /// A rendered sentence rather than a structured reason: the set of things
1571    /// a handover cannot carry keeps changing, and the client only prints it.
1572    HandoverFitness {
1573        /// Why the flock cannot be handed over in place, or `None` when it
1574        /// can.
1575        refusal: Option<String>,
1576    },
1577    /// Answer to `Subscribe`
1578    Subscribed,
1579    /// Answer to `KillDaemon`
1580    ShuttingDown,
1581}
1582
1583/// A request frame
1584// wire format: changing this is a breaking change
1585#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1586pub struct Envelope {
1587    /// Per-connection request id
1588    pub id: u64,
1589    /// Client-imposed deadline (daemon aborts work past it)
1590    pub deadline_ms: Option<u64>,
1591    /// The request
1592    pub body: Request,
1593}
1594
1595/// A reply frame
1596///
1597/// `result` uses serde's stock `Result` representation: the wire carries
1598/// `{"Ok": ...}` / `{"Err": ...}`, with capitalized keys, pinned by snapshot.
1599// wire format: changing this is a breaking change
1600#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1601pub struct Reply {
1602    /// Echoes [`Envelope::id`]
1603    pub id: u64,
1604    /// The outcome
1605    pub result: Result<Response, RpcError>,
1606}
1607
1608/// Handshake outcome: `HelloAck` or a typed refusal, since version skew is
1609/// an error rather than silence. Same `Ok`/`Err` wire shape as
1610/// [`Reply::result`]; refusals use [`RpcErrorCode::ProtocolMismatch`].
1611pub type HelloReply = Result<HelloAck, RpcError>;
1612
1613/// Structured RPC failure
1614// wire format: changing this is a breaking change
1615#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1616pub struct RpcError {
1617    /// Machine-readable code
1618    pub code: RpcErrorCode,
1619    /// Human-readable message (plain English, no theme)
1620    pub message: String,
1621    /// The daemon's own crate version, when it chose to name it.
1622    ///
1623    /// Set on a [`RpcErrorCode::ProtocolMismatch`] refusal, the only place a
1624    /// client can learn it, since [`HelloAck::daemon_version`] never arrives
1625    /// there. `None` on every other error, and on a refusal from a daemon
1626    /// built before the field existed, so a reader treats `None` as unknown
1627    /// and takes the conservative path.
1628    ///
1629    /// Absent on the wire rather than `null`, so
1630    /// [`crate::protocol::PROTOCOL_VERSION`] does not move for it.
1631    #[serde(default, skip_serializing_if = "Option::is_none")]
1632    pub daemon_version: Option<String>,
1633}
1634
1635/// Machine-readable RPC error codes
1636// wire format: changing existing variants is a breaking change
1637#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1638#[serde(rename_all = "snake_case")]
1639#[non_exhaustive]
1640pub enum RpcErrorCode {
1641    /// Selector matched nothing
1642    NotFound,
1643    /// Config failed validation daemon-side
1644    InvalidConfig,
1645    /// Spawn failed (exec error, permissions)
1646    SpawnFailed,
1647    /// Handshake protocol version mismatch
1648    ProtocolMismatch,
1649    /// Unexpected daemon-side failure
1650    Internal,
1651    /// The request's deadline expired before the daemon finished it
1652    DeadlineExceeded,
1653}
1654
1655impl RpcErrorCode {
1656    /// Every variant, for code that needs to iterate them all.
1657    ///
1658    /// `#[non_exhaustive]` forces a `_` arm on any match written outside this
1659    /// crate, which would swallow a variant added here and never updated
1660    /// there (`crates/shep-cli/src/exit.rs` maps every code to an exit
1661    /// status).
1662    pub const ALL: [Self; 6] = [
1663        Self::NotFound,
1664        Self::InvalidConfig,
1665        Self::SpawnFailed,
1666        Self::ProtocolMismatch,
1667        Self::Internal,
1668        Self::DeadlineExceeded,
1669    ];
1670
1671    /// Never called; exists so this crate fails to build if a variant is
1672    /// added to [`RpcErrorCode`] without also being added to [`Self::ALL`].
1673    ///
1674    /// A match here is still checked for exhaustiveness, and each arm indexes
1675    /// a fixed literal position into [`Self::ALL`], so growing the enum
1676    /// without growing the array is an out-of-bounds constant index.
1677    #[allow(dead_code)]
1678    const fn assert_all_lists_every_variant(code: Self) -> Self {
1679        match code {
1680            Self::NotFound => Self::ALL[0],
1681            Self::InvalidConfig => Self::ALL[1],
1682            Self::SpawnFailed => Self::ALL[2],
1683            Self::ProtocolMismatch => Self::ALL[3],
1684            Self::Internal => Self::ALL[4],
1685            Self::DeadlineExceeded => Self::ALL[5],
1686        }
1687    }
1688}
1689
1690#[cfg(test)]
1691mod tests {
1692    use super::*;
1693    use crate::config::AppConfig;
1694    use crate::protocol::PROTOCOL_VERSION;
1695    use crate::status::ProcStatus;
1696
1697    fn sample_info() -> ProcessInfo {
1698        ProcessInfo {
1699            id: 3,
1700            name: "web".to_string(),
1701            status: ProcStatus::Online,
1702            pid: Some(4242),
1703            restarts: 1,
1704            uptime_ms: 60_000,
1705            fold: Some("backend".to_string()),
1706            // Left empty: this fixture feeds `reply_wire_snapshots` and
1707            // `bus_event_wire_snapshots`, so a non-empty value moves pinned
1708            // bytes.
1709            depends_on: Vec::new(),
1710            out_file: Some("/home/ada/.shep/logs/web-0-out.log".to_string()),
1711            err_file: Some("/home/ada/.shep/logs/web-0-err.log".to_string()),
1712            // 12.5: an insta JSON snapshot is stable across platforms only
1713            // for a float the binary representation holds exactly.
1714            cpu_percent: Some(12.5),
1715            memory_bytes: Some(48 * 1024 * 1024),
1716            dog: None,
1717            lambs: None,
1718            last_exit: Some(ExitInfo {
1719                code: Some(1),
1720                signal: None,
1721            }),
1722            smit: None,
1723            instance: None,
1724            handshook: None,
1725            dog_stale: None,
1726            // Left at the builder's default: this fixture feeds
1727            // `reply_wire_snapshots` and `bus_event_wire_snapshots`, so a
1728            // `Some(..)` moves pinned bytes.
1729            pending: None,
1730            overridden: None,
1731            max_memory: Some(512 * 1024 * 1024),
1732        }
1733    }
1734
1735    #[test]
1736    fn a_builder_with_nothing_set_is_a_sheep_that_has_not_run() {
1737        let info = ProcessInfo::builder(3, "web", ProcStatus::Stopped).build();
1738
1739        assert_eq!(info.id, 3);
1740        assert_eq!(info.name, "web");
1741        assert_eq!(info.status, ProcStatus::Stopped);
1742        assert_eq!(info.pid, None);
1743        assert_eq!(info.restarts, 0);
1744        assert_eq!(info.uptime_ms, 0);
1745        assert_eq!(info.fold, None);
1746        assert_eq!(info.out_file, None);
1747        assert_eq!(info.err_file, None);
1748        assert_eq!(info.cpu_percent, None);
1749        assert_eq!(info.memory_bytes, None);
1750        assert_eq!(info.dog, None);
1751        assert_eq!(info.lambs, None);
1752        assert_eq!(info.last_exit, None);
1753    }
1754
1755    /// Every field is given a value distinct from every other field's
1756    /// default, so a copy-pasted setter body shows up as a mismatch.
1757    #[test]
1758    fn every_setter_writes_its_own_field_and_no_other() {
1759        let built = ProcessInfo::builder(3, "web", ProcStatus::Online)
1760            .pid(Some(4242))
1761            .restarts(1)
1762            .uptime_ms(60_000)
1763            .fold(Some("backend".to_string()))
1764            .out_file(Some("/home/ada/.shep/logs/web-0-out.log".to_string()))
1765            .err_file(Some("/home/ada/.shep/logs/web-0-err.log".to_string()))
1766            .cpu_percent(Some(12.5))
1767            .memory_bytes(Some(48 * 1024 * 1024))
1768            .dog(None)
1769            .last_exit(Some(ExitInfo {
1770                code: Some(1),
1771                signal: None,
1772            }))
1773            .max_memory(Some(512 * 1024 * 1024))
1774            .build();
1775
1776        // `sample_info()` is a struct literal on purpose: it is the one
1777        // place that names every field by hand, so this comparison fails the
1778        // day the struct grows a field the builder cannot set.
1779        assert_eq!(built, sample_info());
1780
1781        // `sample_info()`'s `dog` is `None`, the builder's default too, so an
1782        // empty `dog` setter body would pass the comparison above. It cannot
1783        // be changed: it feeds pinned snapshots.
1784        assert_eq!(
1785            ProcessInfo::builder(1, "metrics", ProcStatus::Online)
1786                .dog(Some(DogSource::BuiltIn))
1787                .build()
1788                .dog,
1789            Some(DogSource::BuiltIn),
1790            "an empty `dog` setter body is invisible to the comparison above"
1791        );
1792
1793        // `lambs`, on `dog`'s terms above.
1794        assert_eq!(
1795            ProcessInfo::builder(1, "web", ProcStatus::Online)
1796                .lambs(Some(vec![Lamb::new(4243, "node")]))
1797                .build()
1798                .lambs,
1799            Some(vec![Lamb::new(4243, "node")]),
1800            "an empty `lambs` setter body is invisible to the comparison above"
1801        );
1802
1803        // `smit`, on the same terms, and the field a third party writes: an
1804        // empty setter body drops every dog's mark.
1805        assert_eq!(
1806            ProcessInfo::builder(1, "web", ProcStatus::Online)
1807                .smit(Some("\u{25b2} main@a1b2c3".to_string()))
1808                .build()
1809                .smit
1810                .as_deref(),
1811            Some("\u{25b2} main@a1b2c3"),
1812            "an empty `smit` setter body is invisible to the comparison above"
1813        );
1814
1815        // `handshook`, on the same terms.
1816        assert_eq!(
1817            ProcessInfo::builder(1, "web", ProcStatus::Online)
1818                .handshook(Some(false))
1819                .build()
1820                .handshook,
1821            Some(false),
1822            "an empty `handshook` setter body is invisible to the comparison above"
1823        );
1824
1825        // `dog_stale`, paired with `handshook`: both default to `None`.
1826        assert_eq!(
1827            ProcessInfo::builder(1, "web", ProcStatus::Online)
1828                .dog_stale(Some(true))
1829                .build()
1830                .dog_stale,
1831            Some(true),
1832            "an empty `dog_stale` setter body is invisible to the comparison above"
1833        );
1834
1835        // `pending`, on the same terms.
1836        assert_eq!(
1837            ProcessInfo::builder(1, "web", ProcStatus::Online)
1838                .pending(Some(vec!["env".to_string()]))
1839                .build()
1840                .pending,
1841            Some(vec!["env".to_string()]),
1842            "an empty `pending` setter body is invisible to the comparison above"
1843        );
1844
1845        // `overridden`, on the same terms.
1846        assert_eq!(
1847            ProcessInfo::builder(1, "web", ProcStatus::Online)
1848                .overridden(Some(vec!["cwd".to_string()]))
1849                .build()
1850                .overridden,
1851            Some(vec!["cwd".to_string()]),
1852            "an empty `overridden` setter body is invisible to the comparison above"
1853        );
1854    }
1855
1856    #[test]
1857    fn lambs_distinguishes_not_walked_from_walked_and_empty() {
1858        let not_walked = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
1859        assert_eq!(not_walked.lambs, None);
1860
1861        let walked_empty = ProcessInfo::builder(1, "web", ProcStatus::Online)
1862            .lambs(Some(Vec::new()))
1863            .build();
1864        assert_eq!(walked_empty.lambs, Some(Vec::new()));
1865    }
1866
1867    #[test]
1868    fn a_process_info_without_a_lambs_key_still_deserializes() {
1869        let fixture = r#"{
1870            "id": 3, "name": "web", "status": "online", "pid": 4242,
1871            "restarts": 0, "uptime_ms": 100, "fold": null,
1872            "out_file": null, "err_file": null,
1873            "cpu_percent": null, "memory_bytes": null, "dog": null
1874        }"#;
1875        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
1876        assert_eq!(info.lambs, None);
1877    }
1878
1879    /// argv holds credentials (`--password=`, `?token=`) and
1880    /// `shep describe --format json` is output people paste into issues.
1881    #[test]
1882    fn a_lamb_is_a_pid_and_an_executable_name() {
1883        let lamb = Lamb::new(4243, "node");
1884        let json = serde_json::to_string(&lamb).unwrap();
1885        assert_eq!(json, r#"{"pid":4243,"name":"node"}"#);
1886        assert_eq!(serde_json::from_str::<Lamb>(&json).unwrap(), lamb);
1887    }
1888
1889    #[test]
1890    fn a_dog_source_serializes_snake_case_under_its_kind() {
1891        assert_eq!(
1892            serde_json::to_string(&DogSource::BuiltIn).unwrap(),
1893            r#"{"kind":"built_in"}"#
1894        );
1895        let adopted = DogSource::Adopted {
1896            path: "/usr/local/bin/shep-otel".to_string(),
1897        };
1898        let wire = r#"{"kind":"adopted","path":"/usr/local/bin/shep-otel"}"#;
1899        assert_eq!(serde_json::to_string(&adopted).unwrap(), wire);
1900        assert_eq!(serde_json::from_str::<DogSource>(wire).unwrap(), adopted);
1901    }
1902
1903    #[test]
1904    fn v1_process_info_without_a_dog_marker_still_deserializes() {
1905        let fixture = r#"{"id":3,"name":"web","status":"online","pid":4242,"restarts":1,"uptime_ms":60000,"fold":"backend","out_file":"/l/o.log","err_file":"/l/e.log","cpu_percent":12.5,"memory_bytes":50331648}"#;
1906        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
1907        assert_eq!(info.dog, None);
1908    }
1909
1910    /// No field here carries `#[serde(default)]`: serde's derive resolves a
1911    /// missing key to `None` for a field whose type is syntactically
1912    /// `Option<...>`.
1913    #[test]
1914    fn a_process_info_without_a_last_exit_key_still_deserializes() {
1915        let fixture = r#"{"id":3,"name":"web","status":"online","pid":4242,"restarts":1,"uptime_ms":60000,"fold":"backend","out_file":"/l/o.log","err_file":"/l/e.log","cpu_percent":12.5,"memory_bytes":50331648,"dog":null,"lambs":null}"#;
1916        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
1917        assert_eq!(info.last_exit, None);
1918    }
1919
1920    #[test]
1921    fn a_signal_request_and_its_reply_round_trip() {
1922        let request = Request::Signal {
1923            selector: SelectorSpec::Name("web".to_string()),
1924            signal: "SIGHUP".to_string(),
1925        };
1926        let json = serde_json::to_string(&request).unwrap();
1927        assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
1928
1929        let reply = Response::Signalled(vec![
1930            SignalReply {
1931                id: 1,
1932                name: "web".to_string(),
1933                outcome: SignalOutcome::Delivered,
1934            },
1935            SignalReply {
1936                id: 2,
1937                name: "web".to_string(),
1938                outcome: SignalOutcome::NotRunning,
1939            },
1940            SignalReply {
1941                id: 3,
1942                name: "api".to_string(),
1943                outcome: SignalOutcome::Failed {
1944                    reason: "no such process".to_string(),
1945                },
1946            },
1947        ]);
1948        let json = serde_json::to_string(&reply).unwrap();
1949        assert_eq!(serde_json::from_str::<Response>(&json).unwrap(), reply);
1950        // The three tags, spelled out: a variant renamed in Rust changes
1951        // these strings, compiles clean, and breaks a client matching on them.
1952        assert!(json.contains(r#""kind":"delivered""#), "{json}");
1953        assert!(json.contains(r#""kind":"not_running""#), "{json}");
1954        assert!(json.contains(r#""kind":"failed""#), "{json}");
1955    }
1956
1957    /// `instances` is a per-app number, so `shep stock /web.*/ 4` could mean
1958    /// four each or four total.
1959    #[test]
1960    fn a_scale_request_names_one_app_and_a_count() {
1961        let request = Request::Scale {
1962            name: "web".to_string(),
1963            count: 4,
1964        };
1965        let json = serde_json::to_string(&request).unwrap();
1966        assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
1967        assert!(json.contains(r#""kind":"scale""#), "{json}");
1968        assert!(json.contains(r#""name":"web""#), "{json}");
1969        // No `selector` key at all: this verb is not one of the
1970        // selector-taking family.
1971        assert!(!json.contains("selector"), "{json}");
1972    }
1973
1974    #[test]
1975    fn a_scaled_reply_carries_its_own_tag() {
1976        let json = serde_json::to_string(&Response::Scaled(vec![])).unwrap();
1977        assert_eq!(json, r#"{"kind":"scaled","data":[]}"#);
1978    }
1979
1980    /// `Add` and `Start` carry byte-identical payloads and differ by their
1981    /// `kind` alone.
1982    #[test]
1983    fn an_add_request_and_its_reply_round_trip() {
1984        let request = Request::Add {
1985            apps: vec![AppConfig::minimal("web", "./srv")],
1986        };
1987        let json = serde_json::to_string(&request).unwrap();
1988        assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
1989        assert!(json.contains(r#""kind":"add""#), "{json}");
1990
1991        let reply = Response::Added(vec![]);
1992        let json = serde_json::to_string(&reply).unwrap();
1993        assert_eq!(serde_json::from_str::<Response>(&json).unwrap(), reply);
1994        assert!(json.contains(r#""kind":"added""#), "{json}");
1995    }
1996
1997    /// `NotWritten`'s reason is the only thing separating "the app is not
1998    /// reading its stdin" from "the pipe broke".
1999    #[test]
2000    fn a_send_line_request_and_its_reply_round_trip() {
2001        let request = Request::SendLine {
2002            selector: SelectorSpec::Name("repl".to_string()),
2003            line: "reload-config".to_string(),
2004        };
2005        let json = serde_json::to_string(&request).unwrap();
2006        assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
2007
2008        let reply = Response::SentLine(vec![
2009            LineReply {
2010                id: 1,
2011                name: "repl".to_string(),
2012                outcome: LineOutcome::Sent,
2013            },
2014            LineReply {
2015                id: 2,
2016                name: "web".to_string(),
2017                outcome: LineOutcome::NoStdin,
2018            },
2019            LineReply {
2020                id: 3,
2021                name: "stuck".to_string(),
2022                outcome: LineOutcome::NotWritten {
2023                    reason: "the app did not read its stdin within 2s".to_string(),
2024                },
2025            },
2026        ]);
2027        let json = serde_json::to_string(&reply).unwrap();
2028        assert_eq!(serde_json::from_str::<Response>(&json).unwrap(), reply);
2029        assert!(json.contains(r#""kind":"sent""#), "{json}");
2030        assert!(json.contains(r#""kind":"no_stdin""#), "{json}");
2031        assert!(json.contains("did not read its stdin"), "{json}");
2032    }
2033
2034    #[test]
2035    fn a_line_carrying_a_newline_is_still_one_field_on_the_wire() {
2036        let request = Request::SendLine {
2037            selector: SelectorSpec::All,
2038            line: "a\nb".to_string(),
2039        };
2040        let json = serde_json::to_string(&request).unwrap();
2041        // Escaped, not literal: the frame stays one JSON object. Refusing
2042        // it is the daemon's job, not serde's.
2043        assert!(json.contains(r#""line":"a\nb""#), "{json}");
2044        assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
2045    }
2046
2047    /// Also pins that the newtype protecting this field costs the wire
2048    /// nothing: a bare string either way, so no fixture and no protocol
2049    /// version moves for it.
2050    #[test]
2051    fn env_value_debug_does_not_leak() {
2052        let request = Request::SetSheepEnv {
2053            name: "web".to_string(),
2054            key: "DATABASE_URL".to_string(),
2055            value: Some("postgres://user:hunter2@localhost/app".to_string().into()),
2056        };
2057        let debug = format!("{request:?}");
2058        assert!(!debug.contains("hunter2"), "{debug}");
2059        assert!(debug.contains("EnvValue(<37 bytes>)"), "{debug}");
2060
2061        let json = serde_json::to_string(&request).unwrap();
2062        assert!(
2063            json.contains(r#""value":"postgres://user:hunter2@localhost/app""#),
2064            "{json}"
2065        );
2066        assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
2067    }
2068
2069    /// The pane edits everything else about a sheep, so the config itself
2070    /// has to travel; `env` is the one map in it that holds secrets, and
2071    /// the keys travel while the values never do (IR-41).
2072    #[test]
2073    fn a_sheep_config_view_never_carries_an_env_value() {
2074        let mut config = AppConfig::minimal("web", "./srv");
2075        config
2076            .env
2077            .insert("DB_PASS".to_string(), "hunter2".to_string());
2078        let view = SheepConfigView::new(config, Vec::new(), Vec::new());
2079        assert!(view.config.env.is_empty());
2080        assert_eq!(view.env_keys, ["DB_PASS"]);
2081        let json = serde_json::to_string(&view).unwrap();
2082        assert!(!json.contains("hunter2"), "{json}");
2083    }
2084
2085    /// A `{:?}` on a `Response` reaches it, and `config` holds `args` and
2086    /// `cwd` as well as the env keys (IR-41).
2087    #[test]
2088    fn a_sheep_config_views_debug_is_the_exact_redacted_string() {
2089        let mut config = AppConfig::minimal("web", "./srv");
2090        config.env.insert("A".to_string(), "1".to_string());
2091        let view = SheepConfigView::new(config, vec!["max_restarts".to_string()], Vec::new());
2092        assert_eq!(
2093            format!("{view:?}"),
2094            r#"SheepConfigView { name: "web", env_keys: 1, overridden: 1, pending: 0 }"#
2095        );
2096    }
2097
2098    #[test]
2099    fn request_wire_snapshots() {
2100        let requests = vec![
2101            Envelope {
2102                id: 1,
2103                deadline_ms: Some(5000),
2104                body: Request::Ping,
2105            },
2106            Envelope {
2107                id: 2,
2108                deadline_ms: None,
2109                body: Request::ListFlock,
2110            },
2111            Envelope {
2112                id: 3,
2113                deadline_ms: None,
2114                body: Request::Stop {
2115                    selector: SelectorSpec::Name("web".to_string()),
2116                },
2117            },
2118            Envelope {
2119                id: 4,
2120                deadline_ms: None,
2121                body: Request::Start {
2122                    apps: vec![AppConfig::minimal("web", "./srv")],
2123                },
2124            },
2125            // `All` rather than a named sheep: the selector `shep reopen`
2126            // sends when given no argument.
2127            Envelope {
2128                id: 5,
2129                deadline_ms: None,
2130                body: Request::Reopen {
2131                    selector: SelectorSpec::All,
2132                },
2133            },
2134            // The same selector as the row above, so the two log-plane rows
2135            // differ by their `kind` and by nothing else.
2136            Envelope {
2137                id: 6,
2138                deadline_ms: None,
2139                body: Request::Flush {
2140                    selector: SelectorSpec::All,
2141                },
2142            },
2143            // The same selector as the `stop` row: `reload` under `stop`'s tag
2144            // shows up here as two identical objects.
2145            Envelope {
2146                id: 7,
2147                deadline_ms: None,
2148                body: Request::Reload {
2149                    selector: SelectorSpec::Name("web".to_string()),
2150                },
2151            },
2152            // `action`/`params` match channel.rs's with-params fixture
2153            // verbatim, so a trigger reads the same at every hop.
2154            Envelope {
2155                id: 8,
2156                deadline_ms: None,
2157                body: Request::Trigger {
2158                    selector: SelectorSpec::Name("web".to_string()),
2159                    action: "set-log-level".to_string(),
2160                    params: Some("debug".to_string()),
2161                },
2162            },
2163            // A fieldless verb: a bare `{"kind":"..."}` with no `selector` key.
2164            Envelope {
2165                id: 9,
2166                deadline_ms: None,
2167                body: Request::SaveRoll,
2168            },
2169            // Paired with the `save_roll` row: they differ by their `kind` alone.
2170            Envelope {
2171                id: 10,
2172                deadline_ms: None,
2173                body: Request::Muster,
2174            },
2175            // The three dog verbs. `enable_dog` and `disable_dog` differ by
2176            // their `kind` and by `source` alone.
2177            Envelope {
2178                id: 11,
2179                deadline_ms: None,
2180                body: Request::DogConfig {
2181                    name: "bark".to_string(),
2182                },
2183            },
2184            Envelope {
2185                id: 12,
2186                deadline_ms: None,
2187                body: Request::EnableDog {
2188                    name: "metrics".to_string(),
2189                    source: DogSource::BuiltIn,
2190                },
2191            },
2192            Envelope {
2193                id: 13,
2194                deadline_ms: None,
2195                body: Request::DisableDog {
2196                    name: "metrics".to_string(),
2197                },
2198            },
2199            // `Id`, `Regex` and `Fold` are three newtypes the wire tells apart
2200            // only by their `kind` tag: a `Fold` under `regex`'s tag turns
2201            // `shep restart fold:api` into a regex match.
2202            Envelope {
2203                id: 14,
2204                deadline_ms: None,
2205                body: Request::Describe {
2206                    selector: SelectorSpec::Id(7),
2207                },
2208            },
2209            Envelope {
2210                id: 15,
2211                deadline_ms: None,
2212                body: Request::Describe {
2213                    selector: SelectorSpec::Regex("^web-".to_string()),
2214                },
2215            },
2216            Envelope {
2217                id: 16,
2218                deadline_ms: None,
2219                body: Request::Describe {
2220                    selector: SelectorSpec::Fold("api".to_string()),
2221                },
2222            },
2223            // `SIGHUP` rather than `SIGTERM`: the stop ladder already sends
2224            // TERM, so a TERM fixture could not tell the two frames apart.
2225            Envelope {
2226                id: 17,
2227                deadline_ms: None,
2228                body: Request::Signal {
2229                    selector: SelectorSpec::Name("web".to_string()),
2230                    signal: "SIGHUP".to_string(),
2231                },
2232            },
2233            // The one verb here whose body has no `selector` key.
2234            Envelope {
2235                id: 18,
2236                deadline_ms: None,
2237                body: Request::Scale {
2238                    name: "web".to_string(),
2239                    count: 4,
2240                },
2241            },
2242            // The line carries no terminator on the wire, since the shepherd
2243            // appends it.
2244            Envelope {
2245                id: 19,
2246                deadline_ms: None,
2247                body: Request::SendLine {
2248                    selector: SelectorSpec::All,
2249                    line: "reload-config".to_string(),
2250                },
2251            },
2252            // Both halves of the `Option` are pinned, a paint and a clear, so a
2253            // dog author does not have to guess the clear frame's shape.
2254            Envelope {
2255                id: 20,
2256                deadline_ms: None,
2257                body: Request::SetSmit {
2258                    sheep: "web".to_string(),
2259                    smit: Some(
2260                        "\u{25b2} main@a1b2c3"
2261                            .parse()
2262                            .expect("the reference smit is valid"),
2263                    ),
2264                },
2265            },
2266            Envelope {
2267                id: 21,
2268                deadline_ms: None,
2269                body: Request::SetSmit {
2270                    sheep: "web".to_string(),
2271                    smit: None,
2272                },
2273            },
2274            // An empty `apps`: `start`'s row already pins the payload type, so
2275            // this row's own are the tag and the key the list travels under.
2276            Envelope {
2277                id: 22,
2278                deadline_ms: None,
2279                body: Request::ConfigDrift { apps: Vec::new() },
2280            },
2281            // The only struct-shaped `SelectorSpec` variant, so the only place
2282            // `"kind":"instance"` and the `slot` key are pinned.
2283            Envelope {
2284                id: 23,
2285                deadline_ms: None,
2286                body: Request::Restart {
2287                    selector: SelectorSpec::Instance {
2288                        name: "web".to_string(),
2289                        slot: 2,
2290                    },
2291                },
2292            },
2293            // The one request an older daemon must never be sent: shep-cli
2294            // gates it on the daemon's crate version.
2295            Envelope {
2296                id: 24,
2297                deadline_ms: None,
2298                body: Request::HandoverFitness,
2299            },
2300            // The second request gated on the daemon's crate version.
2301            Envelope {
2302                id: 25,
2303                deadline_ms: None,
2304                body: Request::DogStaleness,
2305            },
2306            // The only request carrying a `DeclaredApp`: a merge keys on what a
2307            // document claimed. `declared_env` is non-empty to show it holds
2308            // env key names and no env value, and `reset` is pinned at a
2309            // non-default depth.
2310            Envelope {
2311                id: 26,
2312                deadline_ms: None,
2313                body: Request::ApplyConfig {
2314                    apps: vec![DeclaredApp {
2315                        config: AppConfig::minimal("web", "./srv"),
2316                        declared: ["name", "script"]
2317                            .iter()
2318                            .map(|k| (*k).to_string())
2319                            .collect(),
2320                        declared_env: ["DATABASE_URL"].iter().map(|k| (*k).to_string()).collect(),
2321                    }],
2322                    reset: ResetDepth::Policy,
2323                },
2324            },
2325            // The same app as the `start` row above: the two differ by their
2326            // `kind` alone, so a mis-tagged `add` shows up as two identical
2327            // objects.
2328            Envelope {
2329                id: 27,
2330                deadline_ms: None,
2331                body: Request::Add {
2332                    apps: vec![AppConfig::minimal("web", "./srv")],
2333                },
2334            },
2335            // The four config-pane requests. `SheepConfig` takes a name
2336            // rather than a selector, like `Scale` and `SetSmit` above and
2337            // for their reason: a pane edits one sheep.
2338            Envelope {
2339                id: 28,
2340                deadline_ms: None,
2341                body: Request::SheepConfig {
2342                    name: "web".to_string(),
2343                },
2344            },
2345            // `value` is pinned as `Some`, because the `None` spelling is
2346            // what removes the key, and a reader that guessed the two apart
2347            // wrongly would delete an operator's env instead of setting it.
2348            // The value is a placeholder, not a secret: this is the one
2349            // request in the enum that carries an env value at all, and it
2350            // travels in one direction only, nothing ever reads it back.
2351            Envelope {
2352                id: 29,
2353                deadline_ms: None,
2354                body: Request::SetSheepEnv {
2355                    name: "web".to_string(),
2356                    key: "DATABASE_URL".to_string(),
2357                    value: Some("postgres://localhost/app".to_string().into()),
2358                },
2359            },
2360            // `SetSheepEnv`'s twin for everything that is not `env`, and
2361            // pinned beside it: the two are one letter apart in the tag and
2362            // a reader that crossed them would write a config field into an
2363            // env map. `value` is a bare JSON value rather than a string,
2364            // which is the half a hand-written reader gets wrong: an
2365            // integer field is an integer here, not `"32"`.
2366            Envelope {
2367                id: 30,
2368                deadline_ms: None,
2369                body: Request::SetSheepField {
2370                    name: "web".to_string(),
2371                    key: "max_restarts".to_string(),
2372                    value: serde_json::json!(32),
2373                },
2374            },
2375            // The second request carrying a `DogSectionToml`, and pinned
2376            // beside its reader: `DogConfig` asks for a section and this
2377            // writes one back, so the two have to agree about the shape a
2378            // section takes on the wire.
2379            Envelope {
2380                id: 31,
2381                deadline_ms: None,
2382                body: Request::SetDogConfig {
2383                    name: "bark".to_string(),
2384                    toml: "debounce = \"30s\"\n".to_string().into(),
2385                },
2386            },
2387        ];
2388        insta::assert_json_snapshot!("request_wire_v6", requests);
2389    }
2390
2391    #[test]
2392    fn reply_wire_snapshots() {
2393        let replies = vec![
2394            Reply {
2395                id: 1,
2396                result: Ok(Response::Pong),
2397            },
2398            Reply {
2399                id: 2,
2400                result: Ok(Response::Flock(vec![sample_info()])),
2401            },
2402            Reply {
2403                id: 3,
2404                result: Err(RpcError {
2405                    code: RpcErrorCode::NotFound,
2406                    message: "no sheep matches `web`".to_string(),
2407                    daemon_version: None,
2408                }),
2409            },
2410            // `ActionReply` is not a `ProcessInfo`. `Replied` is the
2411            // struct-shaped `ActionOutcome` variant and so the one worth
2412            // pinning.
2413            Reply {
2414                id: 4,
2415                result: Ok(Response::Triggered(vec![ActionReply {
2416                    id: 3,
2417                    name: "web".to_string(),
2418                    outcome: ActionOutcome::Replied {
2419                        body: "ok".to_string(),
2420                    },
2421                }])),
2422            },
2423            // The only struct-shaped `Response` variant; every other one is
2424            // a newtype over a `Vec` or a unit, both proven above.
2425            Reply {
2426                id: 5,
2427                result: Ok(Response::RollSaved {
2428                    path: "/home/ada/.shep/flock.json".to_string(),
2429                    apps: 2,
2430                }),
2431            },
2432            // The present `dog` marker; `sample_info()` pins the absent one.
2433            // `Adopted` because it is the variant carrying a payload.
2434            Reply {
2435                id: 6,
2436                result: Ok(Response::Flock(vec![ProcessInfo {
2437                    id: 7,
2438                    name: "otel".to_string(),
2439                    dog: Some(DogSource::Adopted {
2440                        path: "/usr/local/bin/shep-otel".to_string(),
2441                    }),
2442                    ..sample_info()
2443                }])),
2444            },
2445            // The section crosses the wire as text, never a typed structure.
2446            Reply {
2447                id: 7,
2448                result: Ok(Response::DogSection {
2449                    toml: "port = 9615\n".to_string().into(),
2450                }),
2451            },
2452            // The only `Response` variant carrying a bare `ProcessInfo`
2453            // rather than a `Vec`: `enable` starts exactly one dog.
2454            Reply {
2455                id: 8,
2456                result: Ok(Response::DogStarted(ProcessInfo {
2457                    id: 4,
2458                    name: "metrics".to_string(),
2459                    dog: Some(DogSource::BuiltIn),
2460                    ..sample_info()
2461                })),
2462            },
2463            // Each row below carries the smallest body that shows its wire
2464            // shape: the tag is what is being pinned. `Deleted` is a
2465            // `Vec<u32>`; `Subscribed` and `ShuttingDown` carry nothing.
2466            Reply {
2467                id: 9,
2468                result: Ok(Response::Described(vec![])),
2469            },
2470            Reply {
2471                id: 10,
2472                result: Ok(Response::Started(vec![])),
2473            },
2474            Reply {
2475                id: 11,
2476                result: Ok(Response::Stopped(vec![])),
2477            },
2478            Reply {
2479                id: 12,
2480                result: Ok(Response::Restarted(vec![])),
2481            },
2482            // Both halves populated: `refused` is the one field on this
2483            // variant a walk fills and a single-target reload never does.
2484            Reply {
2485                id: 13,
2486                result: Ok(Response::Reloading {
2487                    accepted: vec![],
2488                    refused: vec![SheepRefusal::new("db", "db is already being reloaded")],
2489                }),
2490            },
2491            Reply {
2492                id: 14,
2493                result: Ok(Response::Deleted(vec![7, 8])),
2494            },
2495            Reply {
2496                id: 15,
2497                result: Ok(Response::Reopened(vec![])),
2498            },
2499            Reply {
2500                id: 16,
2501                result: Ok(Response::Flushed(vec![])),
2502            },
2503            Reply {
2504                id: 17,
2505                result: Ok(Response::Mustered(vec![])),
2506            },
2507            Reply {
2508                id: 18,
2509                result: Ok(Response::Subscribed),
2510            },
2511            Reply {
2512                id: 19,
2513                result: Ok(Response::ShuttingDown),
2514            },
2515            // `Signalled`, mirroring the `Triggered` row: one row per
2516            // `SignalOutcome` variant, so no tag is left unproven.
2517            Reply {
2518                id: 20,
2519                result: Ok(Response::Signalled(vec![
2520                    SignalReply {
2521                        id: 1,
2522                        name: "web".to_string(),
2523                        outcome: SignalOutcome::Delivered,
2524                    },
2525                    SignalReply {
2526                        id: 2,
2527                        name: "web".to_string(),
2528                        outcome: SignalOutcome::NotRunning,
2529                    },
2530                    SignalReply {
2531                        id: 3,
2532                        name: "api".to_string(),
2533                        outcome: SignalOutcome::Failed {
2534                            reason: "no such process".to_string(),
2535                        },
2536                    },
2537                ])),
2538            },
2539            Reply {
2540                id: 21,
2541                result: Ok(Response::Scaled(vec![sample_info()])),
2542            },
2543            // `SentLine`, mirroring the `Signalled` row: one row per
2544            // `LineOutcome` variant.
2545            Reply {
2546                id: 22,
2547                result: Ok(Response::SentLine(vec![
2548                    LineReply {
2549                        id: 1,
2550                        name: "repl".to_string(),
2551                        outcome: LineOutcome::Sent,
2552                    },
2553                    LineReply {
2554                        id: 2,
2555                        name: "web".to_string(),
2556                        outcome: LineOutcome::NoStdin,
2557                    },
2558                    LineReply {
2559                        id: 3,
2560                        name: "stuck".to_string(),
2561                        outcome: LineOutcome::NotWritten {
2562                            reason: "the app did not read its stdin within 2s".to_string(),
2563                        },
2564                    },
2565                ])),
2566            },
2567            // A walked lamb tree; every other row pins the `null` shape.
2568            Reply {
2569                id: 23,
2570                result: Ok(Response::Described(vec![
2571                    ProcessInfo::builder(3, "web", ProcStatus::Online)
2572                        .pid(Some(4242))
2573                        .lambs(Some(vec![Lamb::new(4243, "node"), Lamb::new(4244, "sh")]))
2574                        .build(),
2575                ])),
2576            },
2577            // The killed-by-signal shape of `last_exit`; every row above pins
2578            // the exited-normally one. `SIGTERM`'s raw number, since this
2579            // crate carries no name for it.
2580            Reply {
2581                id: 24,
2582                result: Ok(Response::Flock(vec![
2583                    ProcessInfo::builder(5, "worker", ProcStatus::Stopped)
2584                        .restarts(1)
2585                        .last_exit(Some(ExitInfo {
2586                            code: None,
2587                            signal: Some(15),
2588                        }))
2589                        .build(),
2590                ])),
2591            },
2592            // The one row that pins a smit on the wire; `sample_info()` carries
2593            // none.
2594            Reply {
2595                id: 25,
2596                result: Ok(Response::SmitPainted(vec![
2597                    ProcessInfo::builder(3, "web", ProcStatus::Online)
2598                        .pid(Some(4242))
2599                        .smit(Some("\u{25b2} main@a1b2c3".to_string()))
2600                        .build(),
2601                ])),
2602            },
2603            // A sheep drifting in one field and a sheep drifting in several.
2604            // `env` is one of them: the name travels and the value never does.
2605            Reply {
2606                id: 26,
2607                result: Ok(Response::Drifted(vec![
2608                    SheepDrift::new("web", vec!["cwd".to_string()]),
2609                    SheepDrift::new(
2610                        "api",
2611                        vec!["args".to_string(), "env".to_string(), "script".to_string()],
2612                    ),
2613                ])),
2614            },
2615            // The present shape of `instance`; every row above pins its absence.
2616            Reply {
2617                id: 27,
2618                result: Ok(Response::Flock(vec![
2619                    ProcessInfo::builder(9, "web", ProcStatus::Online)
2620                        .pid(Some(5150))
2621                        .instance(Some(2))
2622                        .build(),
2623                ])),
2624            },
2625            // Both shapes of the handover answer; the difference between them
2626            // is a `null`.
2627            Reply {
2628                id: 28,
2629                result: Ok(Response::HandoverFitness { refusal: None }),
2630            },
2631            Reply {
2632                id: 29,
2633                result: Ok(Response::HandoverFitness {
2634                    refusal: Some("sheep 'web' has a shepherd channel".to_string()),
2635                }),
2636            },
2637            // Both lists non-empty and different: the two carry the same wire
2638            // shape.
2639            Reply {
2640                id: 30,
2641                result: Ok(Response::DogStaleness {
2642                    stale: vec!["metrics".to_string()],
2643                    pending: vec!["bark".to_string()],
2644                }),
2645            },
2646            // A dog whose process is up and which has never answered this
2647            // shepherd. `dog_stale: false` is the silence still being waited
2648            // out; the row below is the one it has given up on.
2649            Reply {
2650                id: 31,
2651                result: Ok(Response::Flock(vec![
2652                    ProcessInfo::builder(10, "log-rotate", ProcStatus::Online)
2653                        .pid(Some(208_341))
2654                        .dog(Some(DogSource::Adopted {
2655                            path: "/usr/local/bin/shep-log-rotate".to_string(),
2656                        }))
2657                        .handshook(Some(false))
2658                        .dog_stale(Some(false))
2659                        .build(),
2660                ])),
2661            },
2662            Reply {
2663                id: 32,
2664                result: Ok(Response::Flock(vec![
2665                    ProcessInfo::builder(10, "log-rotate", ProcStatus::Online)
2666                        .pid(Some(208_341))
2667                        .dog(Some(DogSource::Adopted {
2668                            path: "/usr/local/bin/shep-log-rotate".to_string(),
2669                        }))
2670                        .handshook(Some(false))
2671                        .dog_stale(Some(true))
2672                        .build(),
2673                ])),
2674            },
2675            // Three entries, one per shape a load produces: applied, pending,
2676            // refused. `env` is a pending name on purpose: the name travels
2677            // and the value never does.
2678            Reply {
2679                id: 32,
2680                result: Ok(Response::Applied(vec![
2681                    SheepApplied::new("web", vec!["max_memory".to_string()], Vec::new(), None),
2682                    SheepApplied::new(
2683                        "api",
2684                        Vec::new(),
2685                        vec!["args".to_string(), "env".to_string()],
2686                        None,
2687                    ),
2688                    SheepApplied::new(
2689                        "worker",
2690                        Vec::new(),
2691                        Vec::new(),
2692                        Some("worker is not registered".to_string()),
2693                    ),
2694                ])),
2695            },
2696            // `Added`'s tag, all a fixture can prove for a `Vec<ProcessInfo>`
2697            // variant. Down here because every id in this vector is
2698            // hand-written.
2699            Reply {
2700                id: 33,
2701                result: Ok(Response::Added(vec![])),
2702            },
2703            // The config pane's answer, and the row that proves its whole
2704            // security property: `env` serializes as an empty object while
2705            // `env_keys` names the key beside it, so an out-of-tree reader
2706            // learns here that a value never travels (IR-41).
2707            Reply {
2708                id: 34,
2709                result: Ok(Response::SheepConfig(Box::new(SheepConfigView::new(
2710                    {
2711                        let mut config = AppConfig::minimal("web", "./srv");
2712                        config
2713                            .env
2714                            .insert("DATABASE_URL".to_string(), "postgres://x".to_string());
2715                        config
2716                    },
2717                    vec!["max_restarts".to_string()],
2718                    vec!["env".to_string()],
2719                )))),
2720            },
2721            // The three acknowledgements. None echoes what was written:
2722            // `SheepEnvSet` names the key and not its value, for the reason
2723            // the row above pins, `SheepFieldSet` does the same and adds
2724            // the one bit the caller cannot derive, and `DogConfigSet`
2725            // names the dog and not the section.
2726            Reply {
2727                id: 35,
2728                result: Ok(Response::SheepEnvSet {
2729                    name: "web".to_string(),
2730                    key: "DATABASE_URL".to_string(),
2731                }),
2732            },
2733            // `pending` pinned `true`, because `false` is the value a reader
2734            // that dropped the field entirely would decode by accident, and
2735            // the two answers send an operator to different places: one
2736            // says the change is in force, the other says to reload.
2737            Reply {
2738                id: 36,
2739                result: Ok(Response::SheepFieldSet {
2740                    name: "web".to_string(),
2741                    key: "script".to_string(),
2742                    pending: true,
2743                }),
2744            },
2745            Reply {
2746                id: 37,
2747                result: Ok(Response::DogConfigSet {
2748                    name: "bark".to_string(),
2749                }),
2750            },
2751        ];
2752        insta::assert_json_snapshot!("reply_wire_v6", replies);
2753    }
2754
2755    /// Asserts on the JSON, not the struct: a `Vec<String>` cannot say which
2756    /// of the two a string is, so a build carrying a value would typecheck.
2757    #[test]
2758    fn a_sheep_applied_carries_names_and_never_values() {
2759        let applied = SheepApplied::new(
2760            "web",
2761            vec!["cwd".to_string()],
2762            vec!["env".to_string()],
2763            None,
2764        );
2765        let json = serde_json::to_string(&applied).unwrap();
2766        assert!(json.contains("\"env\""), "the NAME travels: {json}");
2767        assert!(
2768            !json.contains("DATABASE_URL"),
2769            "and no value ever does: {json}"
2770        );
2771    }
2772
2773    #[test]
2774    fn a_sheep_applied_debug_prints_the_names_it_was_given() {
2775        let applied = SheepApplied::new("web", vec!["cwd".to_string()], Vec::new(), None);
2776        assert_eq!(
2777            format!("{applied:?}"),
2778            "SheepApplied { name: \"web\", applied: [\"cwd\"], pending: [], refused: None }"
2779        );
2780    }
2781
2782    #[test]
2783    fn a_process_info_without_a_smit_key_still_deserializes() {
2784        let fixture = r#"{"id":1,"name":"web","status":"online","pid":42,"restarts":0,"uptime_ms":10,"fold":null,"out_file":null,"err_file":null,"cpu_percent":null,"memory_bytes":null,"dog":null,"lambs":null,"last_exit":null}"#;
2785        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
2786        assert_eq!(info.smit, None);
2787    }
2788
2789    /// The fixture is a dog's row, where `None` means "render this as it
2790    /// rendered before the field existed", never "never handshaken".
2791    #[test]
2792    fn a_process_info_without_a_handshook_key_still_deserializes() {
2793        let fixture = r#"{"id":1,"name":"metrics","status":"online","pid":42,"restarts":0,"uptime_ms":10,"fold":null,"out_file":null,"err_file":null,"cpu_percent":null,"memory_bytes":null,"dog":{"kind":"built_in"},"lambs":null,"last_exit":null,"smit":null,"instance":0}"#;
2794        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
2795        assert_eq!(info.handshook, None);
2796        assert_eq!(info.dog, Some(DogSource::BuiltIn));
2797    }
2798
2799    /// The fixture carries `handshook: false`, the case that matters: `None`
2800    /// is "no verdict to report", never "it has not given up".
2801    #[test]
2802    fn a_process_info_without_a_dog_stale_key_still_deserializes() {
2803        let fixture = r#"{"id":1,"name":"metrics","status":"online","pid":42,"restarts":0,"uptime_ms":10,"fold":null,"out_file":null,"err_file":null,"cpu_percent":null,"memory_bytes":null,"dog":{"kind":"built_in"},"lambs":null,"last_exit":null,"smit":null,"instance":0,"handshook":false}"#;
2804        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
2805        assert_eq!(info.dog_stale, None);
2806        assert_eq!(info.handshook, Some(false));
2807    }
2808
2809    #[test]
2810    fn a_process_info_carries_its_memory_ceiling_and_defaults_to_none() {
2811        let plain = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
2812        assert_eq!(
2813            plain.max_memory, None,
2814            "a sheep with no ceiling reports none"
2815        );
2816
2817        let capped = ProcessInfo::builder(2, "hungry", ProcStatus::Online)
2818            .max_memory(Some(52 * 1024 * 1024))
2819            .build();
2820        assert_eq!(capped.max_memory, Some(54_525_952));
2821    }
2822
2823    #[test]
2824    fn an_older_daemons_process_info_still_decodes() {
2825        // The field is additive, so a payload written before it existed has to
2826        // decode with the ceiling absent rather than fail the whole envelope.
2827        let older = r#"{"id":1,"name":"web","status":"online","restarts":0,"uptime_ms":0}"#;
2828        let info: ProcessInfo = serde_json::from_str(older).expect("an older payload decodes");
2829        assert_eq!(info.max_memory, None);
2830    }
2831
2832    /// A dog written in another language speaks this wire directly and never
2833    /// runs `FromStr`.
2834    #[test]
2835    fn a_smit_is_validated_when_it_is_deserialized_not_only_when_parsed() {
2836        for bad in [
2837            r#""\u001b[2Jgone""#.to_string(),                    // an escape
2838            r#""a\nb""#.to_string(),                             // a newline
2839            r#""""#.to_string(),                                 // empty
2840            r#""   ""#.to_string(),                              // whitespace
2841            format!(r#""{}""#, "x".repeat(Smit::MAX_CHARS + 1)), // too long
2842        ] {
2843            assert!(
2844                serde_json::from_str::<Smit>(&bad).is_err(),
2845                "a daemon must refuse this on the wire: {bad}"
2846            );
2847        }
2848        assert!(serde_json::from_str::<Smit>(r#""\u25b2 main@a1b2c3""#).is_ok());
2849    }
2850
2851    /// The hand-written `Deserialize` agrees with the derived `Serialize`
2852    /// only while the serialize side stays transparent.
2853    #[test]
2854    fn a_smit_travels_as_a_bare_string() {
2855        let smit: Smit = "\u{25b2} main@a1b2c3".parse().expect("valid");
2856        let json = serde_json::to_string(&smit).unwrap();
2857        assert_eq!(json, "\"\u{25b2} main@a1b2c3\"");
2858        assert_eq!(serde_json::from_str::<Smit>(&json).unwrap(), smit);
2859    }
2860
2861    #[test]
2862    fn a_smit_is_capped_in_characters_not_bytes() {
2863        let cjk = "\u{7f8a}".repeat(Smit::MAX_CHARS);
2864        assert_eq!(cjk.len(), Smit::MAX_CHARS * 3);
2865        assert!(cjk.parse::<Smit>().is_ok(), "{cjk}");
2866        assert_eq!(
2867            "x".repeat(Smit::MAX_CHARS + 1).parse::<Smit>(),
2868            Err(SmitError::TooLong {
2869                chars: Smit::MAX_CHARS + 1
2870            })
2871        );
2872    }
2873
2874    #[test]
2875    fn a_smit_is_stored_exactly_as_it_arrived() {
2876        let padded: Smit = "  main@a1b2c3  ".parse().expect("valid");
2877        assert_eq!(padded.as_str(), "  main@a1b2c3  ");
2878        assert_eq!(padded.to_string(), "  main@a1b2c3  ");
2879    }
2880
2881    #[test]
2882    fn v1_fixture_still_deserializes() {
2883        // Committed byte fixture from protocol v1. If this breaks, bump
2884        // PROTOCOL_VERSION and record it in the CHANGELOG.
2885        let fixture = r#"{"id":7,"deadline_ms":null,"body":{"kind":"stop","selector":{"kind":"name","value":"web"}}}"#;
2886        let env: Envelope = serde_json::from_str(fixture).unwrap();
2887        assert_eq!(env.id, 7);
2888        assert!(matches!(
2889            env.body,
2890            Request::Stop { selector: SelectorSpec::Name(ref n) } if n == "web"
2891        ));
2892    }
2893
2894    #[test]
2895    fn hello_handshake_shape() {
2896        let hello = Hello {
2897            client_version: "0.1.0".to_string(),
2898            protocol: PROTOCOL_VERSION,
2899            dog_name: None,
2900        };
2901        let json = serde_json::to_string(&hello).unwrap();
2902        assert_eq!(json, r#"{"client_version":"0.1.0","protocol":6}"#);
2903    }
2904
2905    #[test]
2906    fn a_dogs_hello_names_the_dog_and_nothing_elses_does() {
2907        let dog = Hello {
2908            client_version: "0.1.0".to_string(),
2909            protocol: PROTOCOL_VERSION,
2910            dog_name: Some("metrics".to_string()),
2911        };
2912        let json = serde_json::to_string(&dog).unwrap();
2913        assert_eq!(
2914            json,
2915            r#"{"client_version":"0.1.0","protocol":6,"dog_name":"metrics"}"#
2916        );
2917        assert_eq!(serde_json::from_str::<Hello>(&json).unwrap(), dog);
2918    }
2919
2920    /// `Hello` is the version-negotiation frame, so `deny_unknown_fields`
2921    /// here would refuse a newer client before `protocol` is read, leaving
2922    /// neither peer able to report the skew.
2923    #[test]
2924    fn a_hello_without_a_dog_name_still_parses() {
2925        let fixture = r#"{"client_version":"0.1.14","protocol":2}"#;
2926        let hello: Hello = serde_json::from_str(fixture).unwrap();
2927        assert_eq!(hello.protocol, 2);
2928        assert_eq!(hello.dog_name, None);
2929
2930        // The other direction: an older daemon ignores a key it does not
2931        // know. `unknown_to_an_older_daemon` stands in for `dog_name`.
2932        let newer = r#"{"client_version":"9.9.9","protocol":2,"dog_name":"metrics","unknown_to_an_older_daemon":true}"#;
2933        let hello: Hello = serde_json::from_str(newer).unwrap();
2934        assert_eq!(hello.protocol, 2);
2935        assert_eq!(hello.dog_name.as_deref(), Some("metrics"));
2936    }
2937
2938    #[test]
2939    fn hello_reply_carries_typed_skew_error() {
2940        let refusal: HelloReply = Err(RpcError {
2941            code: RpcErrorCode::ProtocolMismatch,
2942            message: "daemon speaks protocol 1, client sent 2".to_string(),
2943            daemon_version: None,
2944        });
2945        let json = serde_json::to_string(&refusal).unwrap();
2946        assert_eq!(
2947            json,
2948            r#"{"Err":{"code":"protocol_mismatch","message":"daemon speaks protocol 1, client sent 2"}}"#
2949        );
2950        let back: HelloReply = serde_json::from_str(&json).unwrap();
2951        assert_eq!(back, refusal);
2952    }
2953
2954    #[test]
2955    fn v1_reply_fixture_still_deserializes() {
2956        // Committed byte fixture, protocol v1.
2957        let ok = r#"{"id":1,"result":{"Ok":{"kind":"pong"}}}"#;
2958        let reply: Reply = serde_json::from_str(ok).unwrap();
2959        assert!(matches!(reply.result, Ok(Response::Pong)));
2960        let err = r#"{"id":2,"result":{"Err":{"code":"not_found","message":"no sheep"}}}"#;
2961        let reply: Reply = serde_json::from_str(err).unwrap();
2962        assert_eq!(reply.result.unwrap_err().code, RpcErrorCode::NotFound);
2963    }
2964
2965    #[test]
2966    fn v1_hello_ack_fixture_still_deserializes() {
2967        let fixture = r#"{"Ok":{"daemon_version":"0.1.0","protocol":1,"pid":4242}}"#;
2968        let ack: HelloReply = serde_json::from_str(fixture).unwrap();
2969        assert_eq!(ack.unwrap().pid, 4242);
2970    }
2971
2972    #[test]
2973    fn v1_process_info_without_stats_still_deserializes() {
2974        let fixture = r#"{"id":3,"name":"web","status":"online","pid":4242,"restarts":1,"uptime_ms":60000,"fold":"backend","out_file":"/l/o.log","err_file":"/l/e.log"}"#;
2975        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
2976        assert_eq!(info.cpu_percent, None);
2977        assert_eq!(info.memory_bytes, None);
2978    }
2979
2980    #[test]
2981    fn v1_process_info_without_log_paths_still_deserializes() {
2982        // Committed byte fixture from before `out_file`/`err_file` existed.
2983        let fixture = r#"{"id":3,"name":"web","status":"online","pid":4242,"restarts":1,"uptime_ms":60000,"fold":"backend"}"#;
2984        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
2985        assert_eq!(info.id, 3);
2986        assert_eq!(info.out_file, None);
2987        assert_eq!(info.err_file, None);
2988    }
2989
2990    #[test]
2991    fn an_old_client_still_decodes_a_new_process_info() {
2992        // `ProcessInfo` carries no `deny_unknown_fields`, unlike the config
2993        // types in `crate::config`, so extra keys are ignored.
2994        #[derive(Deserialize)]
2995        struct V1ProcessInfo {
2996            id: u32,
2997            fold: Option<String>,
2998        }
2999
3000        let current = serde_json::to_string(&sample_info()).unwrap();
3001        let old: V1ProcessInfo = serde_json::from_str(&current).unwrap();
3002        assert_eq!(old.id, 3);
3003        assert_eq!(old.fold.as_deref(), Some("backend"));
3004    }
3005
3006    #[test]
3007    fn an_rpc_error_without_a_daemon_version_serializes_exactly_as_before() {
3008        // `skip_serializing_if` is what makes the field free: no
3009        // `"daemon_version":null` key for an older client to ignore.
3010        let plain = RpcError {
3011            code: RpcErrorCode::NotFound,
3012            message: "no sheep".to_string(),
3013            daemon_version: None,
3014        };
3015        assert_eq!(
3016            serde_json::to_string(&plain).unwrap(),
3017            r#"{"code":"not_found","message":"no sheep"}"#
3018        );
3019    }
3020
3021    #[test]
3022    fn a_v1_rpc_error_fixture_deserializes_with_no_daemon_version() {
3023        let fixture =
3024            r#"{"code":"protocol_mismatch","message":"daemon speaks protocol 1, client sent 2"}"#;
3025        let err: RpcError = serde_json::from_str(fixture).unwrap();
3026        assert_eq!(err.code, RpcErrorCode::ProtocolMismatch);
3027        assert_eq!(err.daemon_version, None);
3028    }
3029
3030    #[test]
3031    fn an_old_client_ignores_an_rpc_error_field_it_has_never_seen() {
3032        // `RpcError` carries no `deny_unknown_fields`, so an optional field
3033        // may be added without moving `PROTOCOL_VERSION`.
3034        #[derive(Deserialize)]
3035        struct OldRpcError {
3036            code: RpcErrorCode,
3037            message: String,
3038        }
3039
3040        let current = serde_json::to_string(&RpcError {
3041            code: RpcErrorCode::ProtocolMismatch,
3042            message: "daemon speaks protocol 1, client sent 2".to_string(),
3043            daemon_version: Some("0.1.16".to_string()),
3044        })
3045        .unwrap();
3046        let old: OldRpcError = serde_json::from_str(&current).expect("must tolerate");
3047        assert_eq!(old.code, RpcErrorCode::ProtocolMismatch);
3048        assert_eq!(old.message, "daemon speaks protocol 1, client sent 2");
3049    }
3050
3051    #[test]
3052    fn deadline_exceeded_code_serializes_snake_case() {
3053        assert_eq!(
3054            serde_json::to_string(&RpcErrorCode::DeadlineExceeded).unwrap(),
3055            "\"deadline_exceeded\""
3056        );
3057        assert_eq!(
3058            serde_json::from_str::<RpcErrorCode>("\"deadline_exceeded\"").unwrap(),
3059            RpcErrorCode::DeadlineExceeded
3060        );
3061    }
3062
3063    #[test]
3064    fn action_outcome_kinds_serialize_snake_case_and_round_trip() {
3065        // The shared snapshots exercise only `Replied`, the struct-shaped
3066        // variant.
3067        let cases = [
3068            (
3069                ActionOutcome::Replied {
3070                    body: "pong".to_string(),
3071                },
3072                r#"{"kind":"replied","body":"pong"}"#,
3073            ),
3074            (ActionOutcome::NoChannel, r#"{"kind":"no_channel"}"#),
3075            (ActionOutcome::Skipped, r#"{"kind":"skipped"}"#),
3076            (ActionOutcome::TimedOut, r#"{"kind":"timed_out"}"#),
3077        ];
3078        for (outcome, wire) in cases {
3079            assert_eq!(
3080                serde_json::to_string(&outcome).unwrap(),
3081                wire,
3082                "{outcome:?}"
3083            );
3084            assert_eq!(
3085                serde_json::from_str::<ActionOutcome>(wire).unwrap(),
3086                outcome
3087            );
3088        }
3089    }
3090
3091    #[test]
3092    fn save_roll_serializes_snake_case_with_its_payload_under_data() {
3093        assert_eq!(
3094            serde_json::to_string(&Request::SaveRoll).unwrap(),
3095            r#"{"kind":"save_roll"}"#
3096        );
3097        let reply = Response::RollSaved {
3098            path: "/tmp/flock.json".to_string(),
3099            apps: 3,
3100        };
3101        let wire = r#"{"kind":"roll_saved","data":{"path":"/tmp/flock.json","apps":3}}"#;
3102        assert_eq!(serde_json::to_string(&reply).unwrap(), wire);
3103        assert_eq!(serde_json::from_str::<Response>(wire).unwrap(), reply);
3104    }
3105
3106    /// The listing is empty on purpose: `reply_wire_snapshots` pins the row
3107    /// field by field.
3108    #[test]
3109    fn muster_serializes_snake_case_with_its_listing_under_data() {
3110        assert_eq!(
3111            serde_json::to_string(&Request::Muster).unwrap(),
3112            r#"{"kind":"muster"}"#
3113        );
3114        let reply = Response::Mustered(Vec::new());
3115        let wire = r#"{"kind":"mustered","data":[]}"#;
3116        assert_eq!(serde_json::to_string(&reply).unwrap(), wire);
3117        assert_eq!(serde_json::from_str::<Response>(wire).unwrap(), reply);
3118    }
3119
3120    #[test]
3121    fn the_dog_verbs_serialize_snake_case_with_their_payloads_under_data() {
3122        assert_eq!(
3123            serde_json::to_string(&Request::DogConfig {
3124                name: "bark".to_string()
3125            })
3126            .unwrap(),
3127            r#"{"kind":"dog_config","name":"bark"}"#
3128        );
3129        assert_eq!(
3130            serde_json::to_string(&Request::DisableDog {
3131                name: "bark".to_string()
3132            })
3133            .unwrap(),
3134            r#"{"kind":"disable_dog","name":"bark"}"#
3135        );
3136        let section = Response::DogSection {
3137            toml: "port = 9615\n".to_string().into(),
3138        };
3139        let wire = r#"{"kind":"dog_section","data":{"toml":"port = 9615\n"}}"#;
3140        assert_eq!(serde_json::to_string(&section).unwrap(), wire);
3141        assert_eq!(serde_json::from_str::<Response>(wire).unwrap(), section);
3142    }
3143
3144    #[test]
3145    fn dog_section_toml_debug_does_not_leak() {
3146        // A dog's section routinely holds webhook credentials. Pinned as an
3147        // exact string, so a `#[derive(Debug)]` on `DogSectionToml` fails
3148        // here.
3149        let toml: DogSectionToml =
3150            "webhook_url = \"https://discord.com/api/webhooks/1/super-secret-token\"\n"
3151                .to_string()
3152                .into();
3153        assert_eq!(format!("{toml:?}"), "DogSectionToml(<70 bytes>)");
3154
3155        let response = Response::DogSection { toml };
3156        assert_eq!(
3157            format!("{response:?}"),
3158            "DogSection { toml: DogSectionToml(<70 bytes>) }"
3159        );
3160    }
3161
3162    /// The fixture cannot agree under either candidate order: by id it is
3163    /// `web/1, api/2, web/0`, by name `api, web, web`. The two `web` rows
3164    /// are the tiebreak half, seeded out of order.
3165    #[test]
3166    fn a_listing_sorts_by_name_then_by_id() {
3167        let mut listing = vec![
3168            ProcessInfo::builder(1, "web", ProcStatus::Online).build(),
3169            ProcessInfo::builder(2, "api", ProcStatus::Online).build(),
3170            ProcessInfo::builder(0, "web", ProcStatus::Online).build(),
3171        ];
3172        sort_flock(&mut listing);
3173
3174        let seen: Vec<(&str, u32)> = listing
3175            .iter()
3176            .map(|info| (info.name.as_str(), info.id))
3177            .collect();
3178        assert_eq!(
3179            seen,
3180            vec![("api", 2), ("web", 0), ("web", 1)],
3181            "name first, then id inside a name"
3182        );
3183    }
3184
3185    #[test]
3186    fn an_instance_slot_survives_a_round_trip_and_defaults_to_absent() {
3187        let with = ProcessInfo::builder(1, "web", ProcStatus::Online)
3188            .instance(Some(2))
3189            .build();
3190        assert_eq!(with.instance, Some(2));
3191
3192        let without = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
3193        assert_eq!(
3194            without.instance, None,
3195            "a row nobody set a slot on says so, rather than claiming slot 0"
3196        );
3197    }
3198
3199    #[test]
3200    fn a_reply_from_a_daemon_without_the_field_deserializes_as_absent() {
3201        let json = r#"{"id":1,"name":"web","status":"online","pid":null,
3202            "restarts":0,"uptime_ms":0,"fold":null,"out_file":null,
3203            "err_file":null,"cpu_percent":null,"memory_bytes":null,"dog":null,
3204            "lambs":null,"last_exit":null,"smit":null}"#;
3205        let info: ProcessInfo = serde_json::from_str(json).expect("older reply still parses");
3206        assert_eq!(info.instance, None);
3207    }
3208
3209    #[test]
3210    fn sort_flock_orders_by_slot_before_id() {
3211        // A reload gave slot 0 a fresh, higher id. Slot order must still win.
3212        let mut listing = vec![
3213            ProcessInfo::builder(9, "web", ProcStatus::Online)
3214                .instance(Some(0))
3215                .build(),
3216            ProcessInfo::builder(2, "web", ProcStatus::Online)
3217                .instance(Some(1))
3218                .build(),
3219        ];
3220        sort_flock(&mut listing);
3221        assert_eq!(
3222            listing.iter().map(|i| i.id).collect::<Vec<_>>(),
3223            vec![9, 2],
3224            "slot 0 leads even though its id is higher"
3225        );
3226    }
3227
3228    #[test]
3229    fn sort_flock_falls_back_to_id_when_no_row_carries_a_slot() {
3230        let mut listing = vec![
3231            ProcessInfo::builder(5, "web", ProcStatus::Online).build(),
3232            ProcessInfo::builder(3, "web", ProcStatus::Online).build(),
3233        ];
3234        sort_flock(&mut listing);
3235        assert_eq!(
3236            listing.iter().map(|i| i.id).collect::<Vec<_>>(),
3237            vec![3, 5],
3238            "an older daemon's listing sorts exactly as it does today"
3239        );
3240    }
3241}