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_v3`.
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    /// Stop matching sheep (stay registered)
268    Stop {
269        /// Which sheep
270        selector: SelectorSpec,
271    },
272    /// Restart matching sheep
273    Restart {
274        /// Which sheep
275        selector: SelectorSpec,
276    },
277    /// Replace each matching sheep with a fresh instance of the same app, one
278    /// instance of an app at a time, so the app has a window in which it can
279    /// stay reachable across the swap
280    Reload {
281        /// Which sheep. No default: a reload replaces running processes.
282        selector: SelectorSpec,
283    },
284    /// Stop + deregister matching sheep
285    Delete {
286        /// Which sheep
287        selector: SelectorSpec,
288    },
289    /// Set how many instances one app runs (see `shep stock`).
290    ///
291    /// Takes a name where every other verb takes a [`SelectorSpec`]:
292    /// `instances` is a per-app number and slots are allocated against the
293    /// same-name group, so a selector matching two apps could mean four of
294    /// each or four in total.
295    ///
296    /// The count is absolute: two operators sending `+2` against the same
297    /// app would get a number neither asked for.
298    Scale {
299        /// The app's name, exactly as its config spells it. Not a selector: no
300        /// `all`, no regex, no `fold:`.
301        name: String,
302        /// How many instances the app has when this returns. `0` is refused
303        /// with [`RpcErrorCode::InvalidConfig`]: `shep delete` is the verb
304        /// for removing an app.
305        count: u32,
306    },
307    /// Attach a short marker to `sheep` for `shep flock` to paint, or clear
308    /// it with `None`.
309    ///
310    /// By name, not a selector: a smit belongs to a sheep, and every
311    /// instance of that name shows it, one spawned after the paint included.
312    ///
313    /// Held in memory and scoped to the connection that sent it, so a
314    /// publisher republishes rather than publishing on change.
315    SetSmit {
316        /// Which sheep.
317        sheep: String,
318        /// The marker, or `None` to clear it.
319        smit: Option<Smit>,
320    },
321    /// Reopen every matched sheep's log files, for an external rotator that
322    /// has renamed them (`create`-mode rotation)
323    Reopen {
324        /// Which sheep
325        selector: SelectorSpec,
326    },
327    /// Empty every matched sheep's log files: flush what is still pending,
328    /// then truncate the recorded paths
329    Flush {
330        /// Which sheep. No default: this destroys log data.
331        selector: SelectorSpec,
332    },
333    /// Send a named action to every matched sheep over its shepherd channel
334    /// and report what each app says back (see `shep trigger`).
335    Trigger {
336        /// Which sheep. No default, matching every other verb that reaches
337        /// a running process.
338        selector: SelectorSpec,
339        /// The action name. Free-form: the daemon never declares, parses, or
340        /// validates it, and an app that does not recognize the name is
341        /// expected to say so in its own reply.
342        action: String,
343        /// Argument text, passed through to the app verbatim. One opaque
344        /// string, matching the shepherd channel's own `action` message this
345        /// becomes.
346        params: Option<String>,
347    },
348    /// Deliver one signal to every matched sheep's own process, never its
349    /// process group (see `shep signal`).
350    Signal {
351        /// Which sheep. No default, matching every other verb that reaches
352        /// a running process.
353        selector: SelectorSpec,
354        /// The signal's name, as
355        /// [`OperatorSignal`](crate::signals::OperatorSignal) spells it. The
356        /// `SIG` prefix and the case are both optional; a name outside the
357        /// grammar answers [`RpcErrorCode::InvalidConfig`].
358        signal: String,
359    },
360    /// Write one line to every matched sheep's stdin (see `shep whisper`).
361    SendLine {
362        /// Which sheep. No default, matching every other verb that reaches a
363        /// running process.
364        selector: SelectorSpec,
365        /// The line, without its terminator: the shepherd appends exactly
366        /// one `\n` when it writes.
367        ///
368        /// A line containing an embedded newline is refused
369        /// ([`RpcErrorCode::InvalidConfig`]): it would deliver two commands
370        /// where the operator typed one.
371        line: String,
372    },
373    /// Write the muster roll now, bypassing the snapshot writer's debounce
374    SaveRoll,
375    /// Assemble the flock from the muster roll on disk: start every app the
376    /// roll recorded running, leaving every app the flock already has exactly
377    /// as it stands
378    Muster,
379    /// Ask for one dog's `[dog.<name>]` section, as the dog itself parses it
380    DogConfig {
381        /// The dog's name: the config key, not a selector
382        name: String,
383    },
384    /// Start one dog now, marking it as coming from `source`
385    EnableDog {
386        /// The dog's name
387        name: String,
388        /// Where its binary comes from
389        source: DogSource,
390    },
391    /// Stop and deregister one dog
392    ///
393    /// Answers [`Response::Deleted`]: disabling deregisters exactly as
394    /// `Delete` does.
395    DisableDog {
396        /// The dog's name
397        name: String,
398    },
399    /// Ask which dogs this daemon has given up on, and which it is still
400    /// waiting to hear from (`shep daemon reload`).
401    ///
402    /// Read-only, and about this daemon's own handshakes: take the reading
403    /// after a reload, not before one. Never sent to an older daemon, on
404    /// [`Self::HandoverFitness`]'s terms.
405    ///
406    /// Answers [`Response::DogStaleness`].
407    DogStaleness,
408    /// Ask whether this daemon could hand its flock to a successor in place,
409    /// rather than stopping it and starting it again (`shep daemon reload`).
410    ///
411    /// Read-only: the handover itself is triggered by a signal, which reaches
412    /// a daemon that refuses the client at the handshake.
413    ///
414    /// Answers [`Response::HandoverFitness`]. A refusal is a feature the
415    /// running daemon cannot carry, not an error: the caller falls back to a
416    /// stop-and-start and prints the reason. Never sent to an older daemon:
417    /// shep-cli's `commands::daemon` gates it on the crate version the
418    /// handshake reported.
419    HandoverFitness,
420    /// Graceful daemon shutdown
421    KillDaemon,
422    /// Subscribe this connection to bus topics (glob patterns)
423    Subscribe {
424        /// Topic globs, e.g. `process.*`
425        topics: Vec<String>,
426    },
427}
428
429/// Where a dog came from: this binary, or one an operator adopted.
430///
431/// Carried on [`ProcessInfo::dog`], so a listing distinguishes the two
432/// populations without a second request.
433// wire format: changing existing variants is a breaking change
434#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
435#[serde(tag = "kind", rename_all = "snake_case")]
436#[non_exhaustive]
437pub enum DogSource {
438    /// An argv branch of the shep binary itself (`shep dog <name>`).
439    BuiltIn,
440    /// A binary an operator adopted, run at the daemon's own trust level.
441    Adopted {
442        /// The binary's path, exactly as the operator gave it to `adopt`.
443        path: String,
444    },
445}
446
447/// One process the OS reports as a descendant of a sheep.
448///
449/// Not the set of processes that die with the sheep: this is a parent-pid
450/// walk, where the stop ladder acts on the process group. A lamb that forks
451/// and exits leaves children re-parented to init, out of this list and still
452/// in the group; a `setsid()` grandchild stays in the list and leaves the
453/// group.
454///
455/// `name` is the executable's name (`node`, `sh`), never argv, which carries
456/// credentials and would ride into `shep describe --format json`. Build one
457/// with [`Self::new`].
458// wire format: changing this is a breaking change
459#[non_exhaustive]
460#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
461pub struct Lamb {
462    /// The lamb's own pid.
463    pub pid: u32,
464    /// The executable's name, as the OS reports it. Never its command line.
465    pub name: String,
466}
467
468impl Lamb {
469    /// One lamb.
470    #[must_use]
471    pub fn new(pid: u32, name: impl Into<String>) -> Self {
472        Self {
473            pid,
474            name: name.into(),
475        }
476    }
477}
478
479/// Why a sheep's process most recently stopped existing under this daemon.
480///
481/// Behind [`ProcessInfo::last_exit`]'s own `Option`, so `None` there means
482/// never exited. Ordinarily exactly one of `code`/`signal` is `Some`,
483/// mirroring the OS's `WIFEXITED`/`WIFSIGNALED` split; both `None` together
484/// is legal and means this daemon recorded an exit it could not
485/// characterize.
486// wire format: changing this is a breaking change
487#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
488pub struct ExitInfo {
489    /// The process's own exit code, set on a normal exit (`WIFEXITED`).
490    pub code: Option<i32>,
491    /// The raw unix signal number that ended the process, set when it did
492    /// not exit on its own (`WIFSIGNALED`). An operator's own `shep stop`
493    /// counts: the process still stopped by a signal.
494    ///
495    /// Platform-specific, and never rendered as a name here; that is an
496    /// OS-aware layer's job.
497    pub signal: Option<i32>,
498}
499
500/// Snapshot of one sheep for listings and events
501///
502/// Construct one with [`ProcessInfo::builder`]: `#[non_exhaustive]` forbids
503/// a struct literal outside this crate, though not inside it. The fields
504/// stay `pub`.
505// wire format: changing this is a breaking change. No `Eq`: `cpu_percent` is
506// an `f32`. Paths travel as `String`, since serde's `PathBuf` refuses a
507// non-UTF-8 path and would blank a whole `Reply`. Every added field is an
508// `Option`, so a peer built before it sends no key and `None` reads as unknown.
509#[non_exhaustive]
510#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
511pub struct ProcessInfo {
512    /// Stable numeric id
513    pub id: u32,
514    /// Sheep name
515    pub name: String,
516    /// Lifecycle status
517    pub status: ProcStatus,
518    /// OS pid while running
519    pub pid: Option<u32>,
520    /// Restart count since registration
521    pub restarts: u32,
522    /// Milliseconds since last successful start
523    pub uptime_ms: u64,
524    /// Fold membership
525    pub fold: Option<String>,
526    /// Resolved stdout log path: the app's explicit
527    /// [`AppConfig::out_file`] when it set one, else the daemon-derived
528    /// default. `None` only when the peer daemon predates this field.
529    pub out_file: Option<String>,
530    /// Resolved stderr log path, resolved exactly as [`Self::out_file`]
531    pub err_file: Option<String>,
532    /// Tree CPU as a percentage of one core, over the window since the
533    /// daemon's last periodic sample. `None` when the sheep is not running,
534    /// when it has been up for less than one sampling window, or when the
535    /// peer daemon predates the field; all three render as unknown, never as
536    /// zero. A value over 100 is a tree using more than one core.
537    pub cpu_percent: Option<f32>,
538    /// Tree resident set size in bytes, current as of the reply. `None`
539    /// under the same three conditions as [`Self::cpu_percent`], minus the
540    /// window one: memory needs no baseline.
541    pub memory_bytes: Option<u64>,
542    /// Set when this entry is a dog, naming where the dog came from;
543    /// `None` for a sheep.
544    ///
545    /// Two cases, not [`Self::cpu_percent`]'s three: "not a dog" is the true
546    /// answer whether the peer predates the field or the entry is a sheep.
547    pub dog: Option<DogSource>,
548    /// The processes the OS reports as descendants of this sheep, or `None`
549    /// when this reply did not walk for them.
550    ///
551    /// `None` covers two cases: this reply is not a `Describe` (only
552    /// `Describe` walks), or the peer daemon predates the field.
553    /// `Some(vec![])` is the third: walked, and this sheep has no children.
554    ///
555    /// Read [`Lamb`]'s own doc before rendering this: the list is not the set
556    /// of processes a stop kills, and any output built from it has to say so.
557    pub lambs: Option<Vec<Lamb>>,
558    /// How this sheep's process most recently stopped existing under this
559    /// daemon. `None` while it has never exited under this daemon, and when
560    /// the peer daemon predates the field.
561    ///
562    /// Sticky across a respawn: it answers why the sheep last stopped, not
563    /// whether it is stopped now, and updates only on the next exit.
564    pub last_exit: Option<ExitInfo>,
565    /// The marker a dog has asked to have painted beside this sheep, or
566    /// `None` when no dog has painted one, which also covers a peer daemon
567    /// that predates the field.
568    ///
569    /// A `String` rather than a [`Smit`]: this is a report, and the
570    /// validation that makes it safe to print happened at the daemon's
571    /// ingress. Every instance of a name shows the same marker, since smits
572    /// are keyed by sheep name.
573    pub smit: Option<String>,
574    /// Which instance slot of its app this sheep occupies, counting from 0.
575    ///
576    /// `None` when the peer daemon predates the field. Not a bare `u32`
577    /// defaulted to 0: an app stocked to four instances would report four
578    /// rows all claiming slot 0.
579    pub instance: Option<u32>,
580    /// Whether this dog has completed a handshake with the shepherd that is
581    /// reporting it, and not been refused since; `None` for a sheep.
582    ///
583    /// `None` on [`Self::dog`]'s two-case terms: a sheep has no connection
584    /// to the shepherd, so "no handshake fact to report" is the true answer
585    /// for a sheep and for a peer that predates the field alike.
586    ///
587    /// `Some(false)` is the one that matters: a dog on a protocol this
588    /// shepherd refuses is alive, which is all [`Self::status`] reports, and
589    /// not doing its job. A fact and not a verdict, though: a dog spawned a
590    /// moment ago has not handshaken yet and is healthy.
591    pub handshook: Option<bool>,
592    /// Whether the reporting shepherd has given up on this dog: restarted it
593    /// once for never answering, watched that not help, and stopped
594    /// restarting it. `None` for a sheep, on [`Self::handshook`]'s terms.
595    ///
596    /// Not derivable from [`Self::handshook`]. `Some(false)` there covers
597    /// both a dog spawned three seconds ago that has not dialled back and one
598    /// this shepherd has permanently stopped restarting; the first needs
599    /// nothing done and the second is an incident.
600    ///
601    /// A fact and not a verdict: it says the shepherd stopped, never why.
602    /// The why is in that dog's own log (`shep bleats <dog>`).
603    pub dog_stale: Option<bool>,
604    /// The [`AppConfig`] field names this sheep's spec differs from a load's
605    /// parked config for, in field-name order. `None` when nothing is parked,
606    /// and when the peer daemon predates the field.
607    ///
608    /// Names only, never values, as [`SheepDrift::fields`] carries them: a
609    /// differing `env` reports `"env"` and stops there. `shep reload`
610    /// promotes a parked config.
611    #[serde(default, skip_serializing_if = "Option::is_none")]
612    pub pending: Option<Vec<String>>,
613    /// The [`AppConfig`] field names an operator has set on this sheep that
614    /// its current Flockfile does not declare, in field-name order. `None`
615    /// when there is nothing to report, and when the peer daemon predates
616    /// the field.
617    ///
618    /// Names only, never values, for [`Self::pending`]'s reason:
619    /// [`crate::overrides::AppOverrides::fields`] can hold an `env` value.
620    #[serde(default, skip_serializing_if = "Option::is_none")]
621    pub overridden: Option<Vec<String>>,
622}
623
624/// Orders one flock listing the way every operator-facing surface presents
625/// one: `(name, instance, id)`.
626///
627/// Name first: an id is assigned at registration and a `delete all` plus a
628/// fresh start renumbers the flock, where a name survives. A name is not a
629/// total order on its own, so the id breaks the tie and stays an addressing
630/// key (`shep stop 11`).
631///
632/// A listing whose rows all carry `None` for the slot collapses to
633/// `(name, id)`, since `None` sorts before every `Some`.
634pub fn sort_flock(listing: &mut [ProcessInfo]) {
635    listing.sort_unstable_by(|a, b| {
636        (a.name.as_str(), a.instance, a.id).cmp(&(b.name.as_str(), b.instance, b.id))
637    });
638}
639
640impl ProcessInfo {
641    /// Starts a builder for one sheep's row.
642    ///
643    /// The three arguments are the fields no row can omit and no reader can
644    /// default.
645    ///
646    /// No `#[must_use]`: [`ProcessInfoBuilder`] carries one, which clippy's
647    /// `double_must_use` lint treats as covering this return too.
648    pub fn builder(id: u32, name: impl Into<String>, status: ProcStatus) -> ProcessInfoBuilder {
649        ProcessInfoBuilder {
650            info: Self {
651                id,
652                name: name.into(),
653                status,
654                pid: None,
655                restarts: 0,
656                uptime_ms: 0,
657                fold: None,
658                out_file: None,
659                err_file: None,
660                cpu_percent: None,
661                memory_bytes: None,
662                dog: None,
663                lambs: None,
664                last_exit: None,
665                smit: None,
666                instance: None,
667                handshook: None,
668                dog_stale: None,
669                pending: None,
670                overridden: None,
671            },
672        }
673    }
674}
675
676/// Builds a [`ProcessInfo`], which is `#[non_exhaustive]` and so cannot be
677/// written as a struct literal outside this crate.
678///
679/// Every setter takes the field's own type, `Option` included, so a caller
680/// already holding `Option<u32>` writes `.pid(entry.pid())` rather than an
681/// `if let` ladder. A setter is skipped, not passed `None`, when a row has
682/// nothing to say about that field; the skipped defaults are the ones a
683/// not-yet-running sheep has.
684#[derive(Debug, Clone)]
685#[must_use = "a builder that is never `build`-ed produces no ProcessInfo"]
686pub struct ProcessInfoBuilder {
687    info: ProcessInfo,
688}
689
690impl ProcessInfoBuilder {
691    /// Sets the OS pid; `None` while the sheep is not running.
692    pub fn pid(mut self, pid: Option<u32>) -> Self {
693        self.info.pid = pid;
694        self
695    }
696
697    /// Sets the restart count since registration.
698    pub fn restarts(mut self, restarts: u32) -> Self {
699        self.info.restarts = restarts;
700        self
701    }
702
703    /// Sets milliseconds since the last successful start.
704    pub fn uptime_ms(mut self, uptime_ms: u64) -> Self {
705        self.info.uptime_ms = uptime_ms;
706        self
707    }
708
709    /// Sets fold membership.
710    pub fn fold(mut self, fold: Option<String>) -> Self {
711        self.info.fold = fold;
712        self
713    }
714
715    /// Sets the resolved stdout log path.
716    pub fn out_file(mut self, out_file: Option<String>) -> Self {
717        self.info.out_file = out_file;
718        self
719    }
720
721    /// Sets the resolved stderr log path.
722    pub fn err_file(mut self, err_file: Option<String>) -> Self {
723        self.info.err_file = err_file;
724        self
725    }
726
727    /// Sets tree CPU as a percentage of one core.
728    pub fn cpu_percent(mut self, cpu_percent: Option<f32>) -> Self {
729        self.info.cpu_percent = cpu_percent;
730        self
731    }
732
733    /// Sets tree resident set size in bytes.
734    pub fn memory_bytes(mut self, memory_bytes: Option<u64>) -> Self {
735        self.info.memory_bytes = memory_bytes;
736        self
737    }
738
739    /// Marks this row a dog and names where the dog came from.
740    pub fn dog(mut self, dog: Option<DogSource>) -> Self {
741        self.info.dog = dog;
742        self
743    }
744
745    /// Sets the sheep's lamb list; `None` when this reply did not walk for one.
746    pub fn lambs(mut self, lambs: Option<Vec<Lamb>>) -> Self {
747        self.info.lambs = lambs;
748        self
749    }
750
751    /// Sets how this sheep's process most recently stopped; `None` while it
752    /// has never exited under this daemon.
753    pub fn last_exit(mut self, last_exit: Option<ExitInfo>) -> Self {
754        self.info.last_exit = last_exit;
755        self
756    }
757
758    /// Sets the marker a dog has painted on this sheep; `None` when none has.
759    pub fn smit(mut self, smit: Option<String>) -> Self {
760        self.info.smit = smit;
761        self
762    }
763
764    /// Sets the instance slot; `None` when the peer daemon predates the field.
765    pub fn instance(mut self, instance: Option<u32>) -> Self {
766        self.info.instance = instance;
767        self
768    }
769
770    /// Sets whether this dog has handshaken with the shepherd; `None` for a
771    /// sheep, which has no handshake to report.
772    pub fn handshook(mut self, handshook: Option<bool>) -> Self {
773        self.info.handshook = handshook;
774        self
775    }
776
777    /// Sets whether the shepherd has given up restarting this dog; `None`
778    /// for a sheep, which is never given up on.
779    pub fn dog_stale(mut self, dog_stale: Option<bool>) -> Self {
780        self.info.dog_stale = dog_stale;
781        self
782    }
783
784    /// Sets the field names a load has parked for this sheep's next spawn;
785    /// `None` when nothing is parked.
786    pub fn pending(mut self, pending: Option<Vec<String>>) -> Self {
787        self.info.pending = pending;
788        self
789    }
790
791    /// Sets the field names an operator has overridden on this sheep;
792    /// `None` when there is nothing to report.
793    pub fn overridden(mut self, overridden: Option<Vec<String>>) -> Self {
794        self.info.overridden = overridden;
795        self
796    }
797
798    /// Finishes the row.
799    #[must_use]
800    pub fn build(self) -> ProcessInfo {
801        self.info
802    }
803}
804
805/// What happened when the daemon tried to deliver one sheep's triggered
806/// action.
807// wire format: changing existing variants is a breaking change
808#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
809#[serde(tag = "kind", rename_all = "snake_case")]
810#[non_exhaustive]
811pub enum ActionOutcome {
812    /// The app answered on the shepherd channel.
813    Replied {
814        /// The reply body, exactly as the app sent it.
815        body: String,
816    },
817    /// The sheep had no reachable shepherd channel for the daemon to
818    /// deliver the action over.
819    NoChannel,
820    /// The sheep is a reload drainee, mid-swap and on its way out, so the
821    /// daemon skipped it rather than deliver the action to a process already
822    /// being replaced.
823    Skipped,
824    /// The daemon delivered the action, but no reply arrived before the
825    /// app's configured action timeout elapsed.
826    TimedOut,
827}
828
829/// One matched sheep's row in a `Trigger` reply.
830///
831/// Not a [`ProcessInfo`]: a reply body has nowhere to live on one.
832/// [`Self::outcome`] is per-row, since the selector grammar makes a mixed
833/// flock the normal case.
834// wire format: changing this is a breaking change
835#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
836pub struct ActionReply {
837    /// The sheep's stable id.
838    pub id: u32,
839    /// The sheep's name.
840    pub name: String,
841    /// What happened when the daemon tried to deliver the action.
842    pub outcome: ActionOutcome,
843}
844
845/// What happened when the shepherd tried to deliver one signal.
846// wire format: changing existing variants is a breaking change
847#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
848#[serde(tag = "kind", rename_all = "snake_case")]
849#[non_exhaustive]
850pub enum SignalOutcome {
851    /// The kernel accepted the signal for this sheep's pid.
852    ///
853    /// Says the signal was delivered, not that the app did anything with it.
854    /// A signal the app blocks or ignores is `Delivered` too.
855    Delivered,
856    /// The sheep is registered but has no live process to signal: stopped,
857    /// errored, or waiting out a restart backoff.
858    NotRunning,
859    /// The kernel refused the delivery; carries its reason (`ESRCH` for a
860    /// process reaped between the lookup and the syscall, `EPERM` for one this
861    /// daemon may not signal).
862    Failed {
863        /// The refusal, as the OS worded it.
864        reason: String,
865    },
866}
867
868/// One matched sheep's row in a `Signal` reply.
869///
870/// Per-row like [`ActionReply`]: the selector grammar makes a mixed flock
871/// the normal case.
872// wire format: changing this is a breaking change
873#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
874pub struct SignalReply {
875    /// The sheep's stable id.
876    pub id: u32,
877    /// The sheep's name.
878    pub name: String,
879    /// What happened when the shepherd tried to deliver the signal.
880    pub outcome: SignalOutcome,
881}
882
883/// What happened when the shepherd tried to write one line to a sheep's stdin.
884// wire format: changing existing variants is a breaking change
885#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
886#[serde(tag = "kind", rename_all = "snake_case")]
887#[non_exhaustive]
888pub enum LineOutcome {
889    /// The line was written to the pipe and flushed.
890    ///
891    /// Says the bytes left the shepherd, not that the app read them. A pipe
892    /// holds 64 KiB before it blocks.
893    Sent,
894    /// The sheep has no stdin pipe: its config does not set `stdin = true`, or
895    /// it is not running.
896    ///
897    /// One outcome for two causes: both answer "there is no pipe here".
898    NoStdin,
899    /// The shepherd had a pipe and did not confirm a write to it; carries
900    /// why.
901    ///
902    /// Three shapes reach it: the write failed (the far end is gone), the
903    /// line found the sheep's queue already full, or the write did not
904    /// finish inside the shepherd's own bound. The reason names which.
905    ///
906    /// A timed-out write is not a promise the line was never written: the
907    /// bytes may be part-written into a pipe the app is not draining, and
908    /// land in full the moment it drains. A line still queued behind that one
909    /// is dropped once its caller gives up, so treat a retry as a second
910    /// command.
911    NotWritten {
912        /// What went wrong, in plain English.
913        reason: String,
914    },
915}
916
917/// One matched sheep's row in a `SendLine` reply.
918///
919/// Per-row like [`ActionReply`] and [`SignalReply`].
920// wire format: changing this is a breaking change
921#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
922pub struct LineReply {
923    /// The sheep's stable id.
924    pub id: u32,
925    /// The sheep's name.
926    pub name: String,
927    /// What happened.
928    pub outcome: LineOutcome,
929}
930
931/// A dog's `[dog.<name>]` config section, carried as TOML text.
932///
933/// Travels over the socket rather than the child's environment: a dog's
934/// section routinely holds webhook credentials, and the socket keeps them
935/// out of the process table and out of crash dumps. The manual `Debug`
936/// below prints only a length, since [`Response`] derives `Debug`.
937///
938/// `#[serde(transparent)]`: the wire representation is a bare `String`.
939#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
940#[serde(transparent)]
941pub struct DogSectionToml(String);
942
943impl DogSectionToml {
944    /// The TOML text, empty when the file has no such section.
945    #[must_use]
946    pub fn as_str(&self) -> &str {
947        &self.0
948    }
949}
950
951impl From<String> for DogSectionToml {
952    fn from(toml: String) -> Self {
953        Self(toml)
954    }
955}
956
957impl core::ops::Deref for DogSectionToml {
958    type Target = str;
959
960    fn deref(&self) -> &str {
961        &self.0
962    }
963}
964
965/// Prints a length, never the section body. Pinned as an exact string by
966/// `dog_section_toml_debug_does_not_leak`.
967impl fmt::Debug for DogSectionToml {
968    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
969        write!(f, "DogSectionToml(<{} bytes>)", self.0.len())
970    }
971}
972
973/// One registered sheep whose stored config differs from a caller's copy:
974/// the answer [`Request::ConfigDrift`] is asking for
975///
976/// Field names only, never their values. This is printed at an operator,
977/// and [`AppConfig::env`](crate::config::AppConfig::env) carries secrets,
978/// so a differing `env` reports `"env"` and nothing more. `Debug` is
979/// derived: there is nothing here to redact.
980// wire format: changing field names is a breaking change
981#[non_exhaustive]
982#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
983pub struct SheepDrift {
984    /// The sheep's name. Both configs share it by construction: it is what
985    /// matched them to each other.
986    pub name: String,
987    /// The [`AppConfig`] fields that differ, in field-name order. Never
988    /// empty: a sheep with nothing to report is left out of the answer.
989    pub fields: Vec<String>,
990}
991
992impl SheepDrift {
993    /// Builds one sheep's report.
994    #[must_use]
995    pub fn new(name: impl Into<String>, fields: Vec<String>) -> Self {
996        Self {
997            name: name.into(),
998            fields,
999        }
1000    }
1001}
1002
1003/// What one app's [`Request::ApplyConfig`] did: the answer a load owes the
1004/// operator who ran it
1005///
1006/// One of these per app the request named, found or not and changed or not.
1007///
1008/// [`Self::applied`] and [`Self::pending`] carry field names only, never
1009/// their values, as [`SheepDrift`] does; the merged config never reaches a
1010/// client. [`Self::refused`] is prose and is scoped out of that rule: it
1011/// quotes values out of the file the caller just sent, never out of the
1012/// flock's stored config. `Debug` is derived on that basis: nothing here
1013/// needs redacting.
1014// wire format: changing field names is a breaking change
1015#[non_exhaustive]
1016#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1017pub struct SheepApplied {
1018    /// The sheep's name, exactly as the request spelled it.
1019    pub name: String,
1020    /// Fields now in force, in field-name order. Empty when the load changed
1021    /// nothing the daemon could act on immediately.
1022    pub applied: Vec<String>,
1023    /// Fields the app picks up at its next spawn, in field-name order. Empty
1024    /// when nothing is waiting.
1025    ///
1026    /// `shep reload <name>` promotes them; a client rendering this list says
1027    /// so, since a pending list with no remedy beside it cannot be acted on.
1028    pub pending: Vec<String>,
1029    /// Why some or all of this app's change did not land, in the daemon's own
1030    /// words, or `None` when the whole of it did.
1031    ///
1032    /// Not the same question as the two lists being empty: a refusal raised
1033    /// before anything was touched leaves both empty, and so does a load with
1034    /// nothing to do. It is a sentence rather than a code because the message
1035    /// is what tells them apart.
1036    pub refused: Option<String>,
1037}
1038
1039impl SheepApplied {
1040    /// Builds one app's report.
1041    #[must_use]
1042    pub fn new(
1043        name: impl Into<String>,
1044        applied: Vec<String>,
1045        pending: Vec<String>,
1046        refused: Option<String>,
1047    ) -> Self {
1048        Self {
1049            name: name.into(),
1050            applied,
1051            pending,
1052            refused,
1053        }
1054    }
1055}
1056
1057/// One RPC response (pairs with [`Request`] variants)
1058///
1059/// Ten variants carry a bare `Vec<ProcessInfo>`. Do not collapse them into
1060/// one: each names which request it answers, which is what lets a variant
1061/// diverge without a protocol bump. `Reloading` already means an acceptance
1062/// rather than a result, `Scaled` only the survivors of a scale-down, and
1063/// `Mustered` every sheep of every restored app rather than what this call
1064/// started.
1065// wire format: changing existing variants is a breaking change.
1066// `large_enum_variant` allowed, not fixed: clippy's remedy is to box
1067// `DogStarted`'s payload, a source break for every
1068// `Response::DogStarted(info)` in and out of this workspace.
1069#[allow(clippy::large_enum_variant)]
1070#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1071#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
1072#[non_exhaustive]
1073pub enum Response {
1074    /// Answer to `Ping`
1075    Pong,
1076    /// Answer to `ListFlock`
1077    Flock(Vec<ProcessInfo>),
1078    /// Answer to `Describe`
1079    Described(Vec<ProcessInfo>),
1080    /// Answer to `Start`
1081    Started(Vec<ProcessInfo>),
1082    /// Answer to `Add`: one row per app the request named, registered and
1083    /// spawning nothing.
1084    ///
1085    /// A row here can still be `Online`: `Add` is idempotent by name, so the
1086    /// reply describes the membership the request leaves behind.
1087    Added(Vec<ProcessInfo>),
1088    /// Answer to `ConfigDrift`: one entry per app that is registered under a
1089    /// config different from the one asked about, and no entry for anything
1090    /// else. An empty vector means every app asked about either matches or
1091    /// is not registered at all.
1092    Drifted(Vec<SheepDrift>),
1093    /// Answer to `ApplyConfig`: one entry per app the request named, in the
1094    /// order it named them, the refused and the unchanged included.
1095    ///
1096    /// Complete where [`Self::Drifted`] is filtered: an app missing from
1097    /// "what did you do to each of these" looks like one the daemon dropped.
1098    Applied(Vec<SheepApplied>),
1099    /// Answer to `Stop`
1100    Stopped(Vec<ProcessInfo>),
1101    /// Answer to `Restart`
1102    Restarted(Vec<ProcessInfo>),
1103    /// Answer to `Reload`: an acceptance, not a result.
1104    ///
1105    /// One instance costs a readiness wait plus a drain, so a clustered app
1106    /// outlasts any deadline a client may ask for. The daemon answers as soon
1107    /// as the reload is accepted, with the matched sheep as they stood then,
1108    /// and the swaps report themselves on the bus (`process.reload`,
1109    /// `process.reloaded`, `process.reload_abandoned`). A matched sheep with
1110    /// nothing to replace is listed as the no-op success it is.
1111    Reloading(Vec<ProcessInfo>),
1112    /// Answer to `Scale`: the app's instances that will remain, one row each,
1113    /// ordered by [`sort_flock`]. Every row shares one name, so that is slot
1114    /// order with the id breaking a tie.
1115    ///
1116    /// Scaling down, the departing instances are absent even though their
1117    /// kill ladders are still running; they report themselves on the bus as
1118    /// `process.delete`.
1119    Scaled(Vec<ProcessInfo>),
1120    /// Answer to `SetSmit`: every instance of the named sheep, one row each,
1121    /// carrying the smit as it now stands.
1122    SmitPainted(Vec<ProcessInfo>),
1123    /// Answer to `Delete`: ids removed
1124    Deleted(Vec<u32>),
1125    /// Answer to `Reopen`: every matched sheep, running or not. A sheep with
1126    /// no live log pump has nothing to reopen and is reported as a success,
1127    /// so this carries the same matches `Describe` would.
1128    Reopened(Vec<ProcessInfo>),
1129    /// Answer to `Flush`: one row per matched sheep, running or not, exactly
1130    /// as [`Self::Reopened`].
1131    ///
1132    /// One row per sheep, not per file emptied: several sheep can share one
1133    /// log path, and the daemon truncates each distinct path once.
1134    Flushed(Vec<ProcessInfo>),
1135    /// Answer to `Trigger`: one [`ActionReply`] row per matched sheep, rather
1136    /// than a flock listing, since `ProcessInfo` has nowhere to hold a reply
1137    /// body.
1138    Triggered(Vec<ActionReply>),
1139    /// Answer to `Signal`: one [`SignalReply`] row per matched sheep.
1140    ///
1141    /// Not a flock listing: [`ProcessInfo`] has nowhere to hold a per-sheep
1142    /// outcome.
1143    Signalled(Vec<SignalReply>),
1144    /// Answer to `SendLine`: one [`LineReply`] row per matched sheep.
1145    SentLine(Vec<LineReply>),
1146    /// Answer to `SaveRoll`
1147    RollSaved {
1148        /// Absolute path of the roll the daemon wrote
1149        path: String,
1150        /// How many apps that roll records
1151        apps: u32,
1152    },
1153    /// Answer to `Muster`: every sheep of every app the roll restored, not
1154    /// only the ones this call spawned.
1155    ///
1156    /// Assembling a flock that is already assembled starts nothing, so a
1157    /// listing of what this call spawned would be indistinguishable from an
1158    /// empty roll.
1159    Mustered(Vec<ProcessInfo>),
1160    /// Answer to `DogConfig`: the dog's own section, rendered back to TOML.
1161    ///
1162    /// `toml` is [`DogSectionToml`], whose manual `Debug` keeps the webhook
1163    /// credentials this text carries out of a `{:?}`-formatted `Response`.
1164    DogSection {
1165        /// The `[dog.<name>]` table as TOML text, empty when the file has
1166        /// no such section
1167        toml: DogSectionToml,
1168    },
1169    /// Answer to `EnableDog`: the dog as it stands now
1170    DogStarted(ProcessInfo),
1171    /// Answer to `DogStaleness`: this daemon's own handshake record, split
1172    /// into the dogs it has given up on and the dogs it is still waiting on.
1173    ///
1174    /// Two lists because they answer two questions. `stale` is a finding;
1175    /// `pending` is a reason to ask again, since a reading taken now would
1176    /// be a guess about them.
1177    ///
1178    /// Names only: two builds differing only in the protocol they speak
1179    /// report the same crate version.
1180    DogStaleness {
1181        /// Dogs this daemon has refused twice: once on the handshake that
1182        /// bought them a restart from disk, and again after it. It will not
1183        /// restart them a third time.
1184        stale: Vec<String>,
1185        /// Dogs this daemon is still waiting to hear a final answer from: one
1186        /// whose restart is in flight, or one it supervises that has not
1187        /// handshook yet. Neither stale nor known healthy.
1188        pending: Vec<String>,
1189    },
1190    /// Answer to `HandoverFitness`: `None` when the whole flock can be
1191    /// carried across a daemon handover, and otherwise the sentence saying
1192    /// which sheep cannot be and why.
1193    ///
1194    /// A rendered sentence rather than a structured reason: the set of things
1195    /// a handover cannot carry keeps changing, and the client only prints it.
1196    HandoverFitness {
1197        /// Why the flock cannot be handed over in place, or `None` when it
1198        /// can.
1199        refusal: Option<String>,
1200    },
1201    /// Answer to `Subscribe`
1202    Subscribed,
1203    /// Answer to `KillDaemon`
1204    ShuttingDown,
1205}
1206
1207/// A request frame
1208// wire format: changing this is a breaking change
1209#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1210pub struct Envelope {
1211    /// Per-connection request id
1212    pub id: u64,
1213    /// Client-imposed deadline (daemon aborts work past it)
1214    pub deadline_ms: Option<u64>,
1215    /// The request
1216    pub body: Request,
1217}
1218
1219/// A reply frame
1220///
1221/// `result` uses serde's stock `Result` representation: the wire carries
1222/// `{"Ok": ...}` / `{"Err": ...}`, with capitalized keys, pinned by snapshot.
1223// wire format: changing this is a breaking change
1224#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1225pub struct Reply {
1226    /// Echoes [`Envelope::id`]
1227    pub id: u64,
1228    /// The outcome
1229    pub result: Result<Response, RpcError>,
1230}
1231
1232/// Handshake outcome: `HelloAck` or a typed refusal, since version skew is
1233/// an error rather than silence. Same `Ok`/`Err` wire shape as
1234/// [`Reply::result`]; refusals use [`RpcErrorCode::ProtocolMismatch`].
1235pub type HelloReply = Result<HelloAck, RpcError>;
1236
1237/// Structured RPC failure
1238// wire format: changing this is a breaking change
1239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1240pub struct RpcError {
1241    /// Machine-readable code
1242    pub code: RpcErrorCode,
1243    /// Human-readable message (plain English, no theme)
1244    pub message: String,
1245    /// The daemon's own crate version, when it chose to name it.
1246    ///
1247    /// Set on a [`RpcErrorCode::ProtocolMismatch`] refusal, the only place a
1248    /// client can learn it, since [`HelloAck::daemon_version`] never arrives
1249    /// there. `None` on every other error, and on a refusal from a daemon
1250    /// built before the field existed, so a reader treats `None` as unknown
1251    /// and takes the conservative path.
1252    ///
1253    /// Absent on the wire rather than `null`, so
1254    /// [`crate::protocol::PROTOCOL_VERSION`] does not move for it.
1255    #[serde(default, skip_serializing_if = "Option::is_none")]
1256    pub daemon_version: Option<String>,
1257}
1258
1259/// Machine-readable RPC error codes
1260// wire format: changing existing variants is a breaking change
1261#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1262#[serde(rename_all = "snake_case")]
1263#[non_exhaustive]
1264pub enum RpcErrorCode {
1265    /// Selector matched nothing
1266    NotFound,
1267    /// Config failed validation daemon-side
1268    InvalidConfig,
1269    /// Spawn failed (exec error, permissions)
1270    SpawnFailed,
1271    /// Handshake protocol version mismatch
1272    ProtocolMismatch,
1273    /// Unexpected daemon-side failure
1274    Internal,
1275    /// The request's deadline expired before the daemon finished it
1276    DeadlineExceeded,
1277}
1278
1279impl RpcErrorCode {
1280    /// Every variant, for code that needs to iterate them all.
1281    ///
1282    /// `#[non_exhaustive]` forces a `_` arm on any match written outside this
1283    /// crate, which would swallow a variant added here and never updated
1284    /// there (`crates/shep-cli/src/exit.rs` maps every code to an exit
1285    /// status).
1286    pub const ALL: [Self; 6] = [
1287        Self::NotFound,
1288        Self::InvalidConfig,
1289        Self::SpawnFailed,
1290        Self::ProtocolMismatch,
1291        Self::Internal,
1292        Self::DeadlineExceeded,
1293    ];
1294
1295    /// Never called; exists so this crate fails to build if a variant is
1296    /// added to [`RpcErrorCode`] without also being added to [`Self::ALL`].
1297    ///
1298    /// A match here is still checked for exhaustiveness, and each arm indexes
1299    /// a fixed literal position into [`Self::ALL`], so growing the enum
1300    /// without growing the array is an out-of-bounds constant index.
1301    #[allow(dead_code)]
1302    const fn assert_all_lists_every_variant(code: Self) -> Self {
1303        match code {
1304            Self::NotFound => Self::ALL[0],
1305            Self::InvalidConfig => Self::ALL[1],
1306            Self::SpawnFailed => Self::ALL[2],
1307            Self::ProtocolMismatch => Self::ALL[3],
1308            Self::Internal => Self::ALL[4],
1309            Self::DeadlineExceeded => Self::ALL[5],
1310        }
1311    }
1312}
1313
1314#[cfg(test)]
1315mod tests {
1316    use super::*;
1317    use crate::config::AppConfig;
1318    use crate::protocol::PROTOCOL_VERSION;
1319    use crate::status::ProcStatus;
1320
1321    fn sample_info() -> ProcessInfo {
1322        ProcessInfo {
1323            id: 3,
1324            name: "web".to_string(),
1325            status: ProcStatus::Online,
1326            pid: Some(4242),
1327            restarts: 1,
1328            uptime_ms: 60_000,
1329            fold: Some("backend".to_string()),
1330            out_file: Some("/home/ada/.shep/logs/web-0-out.log".to_string()),
1331            err_file: Some("/home/ada/.shep/logs/web-0-err.log".to_string()),
1332            // 12.5: an insta JSON snapshot is stable across platforms only
1333            // for a float the binary representation holds exactly.
1334            cpu_percent: Some(12.5),
1335            memory_bytes: Some(48 * 1024 * 1024),
1336            dog: None,
1337            lambs: None,
1338            last_exit: Some(ExitInfo {
1339                code: Some(1),
1340                signal: None,
1341            }),
1342            smit: None,
1343            instance: None,
1344            handshook: None,
1345            dog_stale: None,
1346            // Left at the builder's default: this fixture feeds
1347            // `reply_wire_snapshots` and `bus_event_wire_snapshots`, so a
1348            // `Some(..)` moves pinned bytes.
1349            pending: None,
1350            overridden: None,
1351        }
1352    }
1353
1354    #[test]
1355    fn a_builder_with_nothing_set_is_a_sheep_that_has_not_run() {
1356        let info = ProcessInfo::builder(3, "web", ProcStatus::Stopped).build();
1357
1358        assert_eq!(info.id, 3);
1359        assert_eq!(info.name, "web");
1360        assert_eq!(info.status, ProcStatus::Stopped);
1361        assert_eq!(info.pid, None);
1362        assert_eq!(info.restarts, 0);
1363        assert_eq!(info.uptime_ms, 0);
1364        assert_eq!(info.fold, None);
1365        assert_eq!(info.out_file, None);
1366        assert_eq!(info.err_file, None);
1367        assert_eq!(info.cpu_percent, None);
1368        assert_eq!(info.memory_bytes, None);
1369        assert_eq!(info.dog, None);
1370        assert_eq!(info.lambs, None);
1371        assert_eq!(info.last_exit, None);
1372    }
1373
1374    /// Every field is given a value distinct from every other field's
1375    /// default, so a copy-pasted setter body shows up as a mismatch.
1376    #[test]
1377    fn every_setter_writes_its_own_field_and_no_other() {
1378        let built = ProcessInfo::builder(3, "web", ProcStatus::Online)
1379            .pid(Some(4242))
1380            .restarts(1)
1381            .uptime_ms(60_000)
1382            .fold(Some("backend".to_string()))
1383            .out_file(Some("/home/ada/.shep/logs/web-0-out.log".to_string()))
1384            .err_file(Some("/home/ada/.shep/logs/web-0-err.log".to_string()))
1385            .cpu_percent(Some(12.5))
1386            .memory_bytes(Some(48 * 1024 * 1024))
1387            .dog(None)
1388            .last_exit(Some(ExitInfo {
1389                code: Some(1),
1390                signal: None,
1391            }))
1392            .build();
1393
1394        // `sample_info()` is a struct literal on purpose: it is the one
1395        // place that names every field by hand, so this comparison fails the
1396        // day the struct grows a field the builder cannot set.
1397        assert_eq!(built, sample_info());
1398
1399        // `sample_info()`'s `dog` is `None`, the builder's default too, so an
1400        // empty `dog` setter body would pass the comparison above. It cannot
1401        // be changed: it feeds pinned snapshots.
1402        assert_eq!(
1403            ProcessInfo::builder(1, "metrics", ProcStatus::Online)
1404                .dog(Some(DogSource::BuiltIn))
1405                .build()
1406                .dog,
1407            Some(DogSource::BuiltIn),
1408            "an empty `dog` setter body is invisible to the comparison above"
1409        );
1410
1411        // `lambs`, on `dog`'s terms above.
1412        assert_eq!(
1413            ProcessInfo::builder(1, "web", ProcStatus::Online)
1414                .lambs(Some(vec![Lamb::new(4243, "node")]))
1415                .build()
1416                .lambs,
1417            Some(vec![Lamb::new(4243, "node")]),
1418            "an empty `lambs` setter body is invisible to the comparison above"
1419        );
1420
1421        // `smit`, on the same terms, and the field a third party writes: an
1422        // empty setter body drops every dog's mark.
1423        assert_eq!(
1424            ProcessInfo::builder(1, "web", ProcStatus::Online)
1425                .smit(Some("\u{25b2} main@a1b2c3".to_string()))
1426                .build()
1427                .smit
1428                .as_deref(),
1429            Some("\u{25b2} main@a1b2c3"),
1430            "an empty `smit` setter body is invisible to the comparison above"
1431        );
1432
1433        // `handshook`, on the same terms.
1434        assert_eq!(
1435            ProcessInfo::builder(1, "web", ProcStatus::Online)
1436                .handshook(Some(false))
1437                .build()
1438                .handshook,
1439            Some(false),
1440            "an empty `handshook` setter body is invisible to the comparison above"
1441        );
1442
1443        // `dog_stale`, paired with `handshook`: both default to `None`.
1444        assert_eq!(
1445            ProcessInfo::builder(1, "web", ProcStatus::Online)
1446                .dog_stale(Some(true))
1447                .build()
1448                .dog_stale,
1449            Some(true),
1450            "an empty `dog_stale` setter body is invisible to the comparison above"
1451        );
1452
1453        // `pending`, on the same terms.
1454        assert_eq!(
1455            ProcessInfo::builder(1, "web", ProcStatus::Online)
1456                .pending(Some(vec!["env".to_string()]))
1457                .build()
1458                .pending,
1459            Some(vec!["env".to_string()]),
1460            "an empty `pending` setter body is invisible to the comparison above"
1461        );
1462
1463        // `overridden`, on the same terms.
1464        assert_eq!(
1465            ProcessInfo::builder(1, "web", ProcStatus::Online)
1466                .overridden(Some(vec!["cwd".to_string()]))
1467                .build()
1468                .overridden,
1469            Some(vec!["cwd".to_string()]),
1470            "an empty `overridden` setter body is invisible to the comparison above"
1471        );
1472    }
1473
1474    #[test]
1475    fn lambs_distinguishes_not_walked_from_walked_and_empty() {
1476        let not_walked = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
1477        assert_eq!(not_walked.lambs, None);
1478
1479        let walked_empty = ProcessInfo::builder(1, "web", ProcStatus::Online)
1480            .lambs(Some(Vec::new()))
1481            .build();
1482        assert_eq!(walked_empty.lambs, Some(Vec::new()));
1483    }
1484
1485    #[test]
1486    fn a_process_info_without_a_lambs_key_still_deserializes() {
1487        let fixture = r#"{
1488            "id": 3, "name": "web", "status": "online", "pid": 4242,
1489            "restarts": 0, "uptime_ms": 100, "fold": null,
1490            "out_file": null, "err_file": null,
1491            "cpu_percent": null, "memory_bytes": null, "dog": null
1492        }"#;
1493        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
1494        assert_eq!(info.lambs, None);
1495    }
1496
1497    /// argv holds credentials (`--password=`, `?token=`) and
1498    /// `shep describe --format json` is output people paste into issues.
1499    #[test]
1500    fn a_lamb_is_a_pid_and_an_executable_name() {
1501        let lamb = Lamb::new(4243, "node");
1502        let json = serde_json::to_string(&lamb).unwrap();
1503        assert_eq!(json, r#"{"pid":4243,"name":"node"}"#);
1504        assert_eq!(serde_json::from_str::<Lamb>(&json).unwrap(), lamb);
1505    }
1506
1507    #[test]
1508    fn a_dog_source_serializes_snake_case_under_its_kind() {
1509        assert_eq!(
1510            serde_json::to_string(&DogSource::BuiltIn).unwrap(),
1511            r#"{"kind":"built_in"}"#
1512        );
1513        let adopted = DogSource::Adopted {
1514            path: "/usr/local/bin/shep-otel".to_string(),
1515        };
1516        let wire = r#"{"kind":"adopted","path":"/usr/local/bin/shep-otel"}"#;
1517        assert_eq!(serde_json::to_string(&adopted).unwrap(), wire);
1518        assert_eq!(serde_json::from_str::<DogSource>(wire).unwrap(), adopted);
1519    }
1520
1521    #[test]
1522    fn v1_process_info_without_a_dog_marker_still_deserializes() {
1523        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}"#;
1524        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
1525        assert_eq!(info.dog, None);
1526    }
1527
1528    /// No field here carries `#[serde(default)]`: serde's derive resolves a
1529    /// missing key to `None` for a field whose type is syntactically
1530    /// `Option<...>`.
1531    #[test]
1532    fn a_process_info_without_a_last_exit_key_still_deserializes() {
1533        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}"#;
1534        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
1535        assert_eq!(info.last_exit, None);
1536    }
1537
1538    #[test]
1539    fn a_signal_request_and_its_reply_round_trip() {
1540        let request = Request::Signal {
1541            selector: SelectorSpec::Name("web".to_string()),
1542            signal: "SIGHUP".to_string(),
1543        };
1544        let json = serde_json::to_string(&request).unwrap();
1545        assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
1546
1547        let reply = Response::Signalled(vec![
1548            SignalReply {
1549                id: 1,
1550                name: "web".to_string(),
1551                outcome: SignalOutcome::Delivered,
1552            },
1553            SignalReply {
1554                id: 2,
1555                name: "web".to_string(),
1556                outcome: SignalOutcome::NotRunning,
1557            },
1558            SignalReply {
1559                id: 3,
1560                name: "api".to_string(),
1561                outcome: SignalOutcome::Failed {
1562                    reason: "no such process".to_string(),
1563                },
1564            },
1565        ]);
1566        let json = serde_json::to_string(&reply).unwrap();
1567        assert_eq!(serde_json::from_str::<Response>(&json).unwrap(), reply);
1568        // The three tags, spelled out: a variant renamed in Rust changes
1569        // these strings, compiles clean, and breaks a client matching on them.
1570        assert!(json.contains(r#""kind":"delivered""#), "{json}");
1571        assert!(json.contains(r#""kind":"not_running""#), "{json}");
1572        assert!(json.contains(r#""kind":"failed""#), "{json}");
1573    }
1574
1575    /// `instances` is a per-app number, so `shep stock /web.*/ 4` could mean
1576    /// four each or four total.
1577    #[test]
1578    fn a_scale_request_names_one_app_and_a_count() {
1579        let request = Request::Scale {
1580            name: "web".to_string(),
1581            count: 4,
1582        };
1583        let json = serde_json::to_string(&request).unwrap();
1584        assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
1585        assert!(json.contains(r#""kind":"scale""#), "{json}");
1586        assert!(json.contains(r#""name":"web""#), "{json}");
1587        // No `selector` key at all: this verb is not one of the
1588        // selector-taking family.
1589        assert!(!json.contains("selector"), "{json}");
1590    }
1591
1592    #[test]
1593    fn a_scaled_reply_carries_its_own_tag() {
1594        let json = serde_json::to_string(&Response::Scaled(vec![])).unwrap();
1595        assert_eq!(json, r#"{"kind":"scaled","data":[]}"#);
1596    }
1597
1598    /// `Add` and `Start` carry byte-identical payloads and differ by their
1599    /// `kind` alone.
1600    #[test]
1601    fn an_add_request_and_its_reply_round_trip() {
1602        let request = Request::Add {
1603            apps: vec![AppConfig::minimal("web", "./srv")],
1604        };
1605        let json = serde_json::to_string(&request).unwrap();
1606        assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
1607        assert!(json.contains(r#""kind":"add""#), "{json}");
1608
1609        let reply = Response::Added(vec![]);
1610        let json = serde_json::to_string(&reply).unwrap();
1611        assert_eq!(serde_json::from_str::<Response>(&json).unwrap(), reply);
1612        assert!(json.contains(r#""kind":"added""#), "{json}");
1613    }
1614
1615    /// `NotWritten`'s reason is the only thing separating "the app is not
1616    /// reading its stdin" from "the pipe broke".
1617    #[test]
1618    fn a_send_line_request_and_its_reply_round_trip() {
1619        let request = Request::SendLine {
1620            selector: SelectorSpec::Name("repl".to_string()),
1621            line: "reload-config".to_string(),
1622        };
1623        let json = serde_json::to_string(&request).unwrap();
1624        assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
1625
1626        let reply = Response::SentLine(vec![
1627            LineReply {
1628                id: 1,
1629                name: "repl".to_string(),
1630                outcome: LineOutcome::Sent,
1631            },
1632            LineReply {
1633                id: 2,
1634                name: "web".to_string(),
1635                outcome: LineOutcome::NoStdin,
1636            },
1637            LineReply {
1638                id: 3,
1639                name: "stuck".to_string(),
1640                outcome: LineOutcome::NotWritten {
1641                    reason: "the app did not read its stdin within 2s".to_string(),
1642                },
1643            },
1644        ]);
1645        let json = serde_json::to_string(&reply).unwrap();
1646        assert_eq!(serde_json::from_str::<Response>(&json).unwrap(), reply);
1647        assert!(json.contains(r#""kind":"sent""#), "{json}");
1648        assert!(json.contains(r#""kind":"no_stdin""#), "{json}");
1649        assert!(json.contains("did not read its stdin"), "{json}");
1650    }
1651
1652    #[test]
1653    fn a_line_carrying_a_newline_is_still_one_field_on_the_wire() {
1654        let request = Request::SendLine {
1655            selector: SelectorSpec::All,
1656            line: "a\nb".to_string(),
1657        };
1658        let json = serde_json::to_string(&request).unwrap();
1659        // Escaped, not literal: the frame stays one JSON object. Refusing
1660        // it is the daemon's job, not serde's.
1661        assert!(json.contains(r#""line":"a\nb""#), "{json}");
1662        assert_eq!(serde_json::from_str::<Request>(&json).unwrap(), request);
1663    }
1664
1665    #[test]
1666    fn request_wire_snapshots() {
1667        let requests = vec![
1668            Envelope {
1669                id: 1,
1670                deadline_ms: Some(5000),
1671                body: Request::Ping,
1672            },
1673            Envelope {
1674                id: 2,
1675                deadline_ms: None,
1676                body: Request::ListFlock,
1677            },
1678            Envelope {
1679                id: 3,
1680                deadline_ms: None,
1681                body: Request::Stop {
1682                    selector: SelectorSpec::Name("web".to_string()),
1683                },
1684            },
1685            Envelope {
1686                id: 4,
1687                deadline_ms: None,
1688                body: Request::Start {
1689                    apps: vec![AppConfig::minimal("web", "./srv")],
1690                },
1691            },
1692            // `All` rather than a named sheep: the selector `shep reopen`
1693            // sends when given no argument.
1694            Envelope {
1695                id: 5,
1696                deadline_ms: None,
1697                body: Request::Reopen {
1698                    selector: SelectorSpec::All,
1699                },
1700            },
1701            // The same selector as the row above, so the two log-plane rows
1702            // differ by their `kind` and by nothing else.
1703            Envelope {
1704                id: 6,
1705                deadline_ms: None,
1706                body: Request::Flush {
1707                    selector: SelectorSpec::All,
1708                },
1709            },
1710            // The same selector as the `stop` row: `reload` under `stop`'s tag
1711            // shows up here as two identical objects.
1712            Envelope {
1713                id: 7,
1714                deadline_ms: None,
1715                body: Request::Reload {
1716                    selector: SelectorSpec::Name("web".to_string()),
1717                },
1718            },
1719            // `action`/`params` match channel.rs's with-params fixture
1720            // verbatim, so a trigger reads the same at every hop.
1721            Envelope {
1722                id: 8,
1723                deadline_ms: None,
1724                body: Request::Trigger {
1725                    selector: SelectorSpec::Name("web".to_string()),
1726                    action: "set-log-level".to_string(),
1727                    params: Some("debug".to_string()),
1728                },
1729            },
1730            // A fieldless verb: a bare `{"kind":"..."}` with no `selector` key.
1731            Envelope {
1732                id: 9,
1733                deadline_ms: None,
1734                body: Request::SaveRoll,
1735            },
1736            // Paired with the `save_roll` row: they differ by their `kind` alone.
1737            Envelope {
1738                id: 10,
1739                deadline_ms: None,
1740                body: Request::Muster,
1741            },
1742            // The three dog verbs. `enable_dog` and `disable_dog` differ by
1743            // their `kind` and by `source` alone.
1744            Envelope {
1745                id: 11,
1746                deadline_ms: None,
1747                body: Request::DogConfig {
1748                    name: "bark".to_string(),
1749                },
1750            },
1751            Envelope {
1752                id: 12,
1753                deadline_ms: None,
1754                body: Request::EnableDog {
1755                    name: "metrics".to_string(),
1756                    source: DogSource::BuiltIn,
1757                },
1758            },
1759            Envelope {
1760                id: 13,
1761                deadline_ms: None,
1762                body: Request::DisableDog {
1763                    name: "metrics".to_string(),
1764                },
1765            },
1766            // `Id`, `Regex` and `Fold` are three newtypes the wire tells apart
1767            // only by their `kind` tag: a `Fold` under `regex`'s tag turns
1768            // `shep restart fold:api` into a regex match.
1769            Envelope {
1770                id: 14,
1771                deadline_ms: None,
1772                body: Request::Describe {
1773                    selector: SelectorSpec::Id(7),
1774                },
1775            },
1776            Envelope {
1777                id: 15,
1778                deadline_ms: None,
1779                body: Request::Describe {
1780                    selector: SelectorSpec::Regex("^web-".to_string()),
1781                },
1782            },
1783            Envelope {
1784                id: 16,
1785                deadline_ms: None,
1786                body: Request::Describe {
1787                    selector: SelectorSpec::Fold("api".to_string()),
1788                },
1789            },
1790            // `SIGHUP` rather than `SIGTERM`: the stop ladder already sends
1791            // TERM, so a TERM fixture could not tell the two frames apart.
1792            Envelope {
1793                id: 17,
1794                deadline_ms: None,
1795                body: Request::Signal {
1796                    selector: SelectorSpec::Name("web".to_string()),
1797                    signal: "SIGHUP".to_string(),
1798                },
1799            },
1800            // The one verb here whose body has no `selector` key.
1801            Envelope {
1802                id: 18,
1803                deadline_ms: None,
1804                body: Request::Scale {
1805                    name: "web".to_string(),
1806                    count: 4,
1807                },
1808            },
1809            // The line carries no terminator on the wire, since the shepherd
1810            // appends it.
1811            Envelope {
1812                id: 19,
1813                deadline_ms: None,
1814                body: Request::SendLine {
1815                    selector: SelectorSpec::All,
1816                    line: "reload-config".to_string(),
1817                },
1818            },
1819            // Both halves of the `Option` are pinned, a paint and a clear, so a
1820            // dog author does not have to guess the clear frame's shape.
1821            Envelope {
1822                id: 20,
1823                deadline_ms: None,
1824                body: Request::SetSmit {
1825                    sheep: "web".to_string(),
1826                    smit: Some(
1827                        "\u{25b2} main@a1b2c3"
1828                            .parse()
1829                            .expect("the reference smit is valid"),
1830                    ),
1831                },
1832            },
1833            Envelope {
1834                id: 21,
1835                deadline_ms: None,
1836                body: Request::SetSmit {
1837                    sheep: "web".to_string(),
1838                    smit: None,
1839                },
1840            },
1841            // An empty `apps`: `start`'s row already pins the payload type, so
1842            // this row's own are the tag and the key the list travels under.
1843            Envelope {
1844                id: 22,
1845                deadline_ms: None,
1846                body: Request::ConfigDrift { apps: Vec::new() },
1847            },
1848            // The only struct-shaped `SelectorSpec` variant, so the only place
1849            // `"kind":"instance"` and the `slot` key are pinned.
1850            Envelope {
1851                id: 23,
1852                deadline_ms: None,
1853                body: Request::Restart {
1854                    selector: SelectorSpec::Instance {
1855                        name: "web".to_string(),
1856                        slot: 2,
1857                    },
1858                },
1859            },
1860            // The one request an older daemon must never be sent: shep-cli
1861            // gates it on the daemon's crate version.
1862            Envelope {
1863                id: 24,
1864                deadline_ms: None,
1865                body: Request::HandoverFitness,
1866            },
1867            // The second request gated on the daemon's crate version.
1868            Envelope {
1869                id: 25,
1870                deadline_ms: None,
1871                body: Request::DogStaleness,
1872            },
1873            // The only request carrying a `DeclaredApp`: a merge keys on what a
1874            // document claimed. `declared_env` is non-empty to show it holds
1875            // env key names and no env value, and `reset` is pinned at a
1876            // non-default depth.
1877            Envelope {
1878                id: 26,
1879                deadline_ms: None,
1880                body: Request::ApplyConfig {
1881                    apps: vec![DeclaredApp {
1882                        config: AppConfig::minimal("web", "./srv"),
1883                        declared: ["name", "script"]
1884                            .iter()
1885                            .map(|k| (*k).to_string())
1886                            .collect(),
1887                        declared_env: ["DATABASE_URL"].iter().map(|k| (*k).to_string()).collect(),
1888                    }],
1889                    reset: ResetDepth::Policy,
1890                },
1891            },
1892            // The same app as the `start` row above: the two differ by their
1893            // `kind` alone, so a mis-tagged `add` shows up as two identical
1894            // objects.
1895            Envelope {
1896                id: 27,
1897                deadline_ms: None,
1898                body: Request::Add {
1899                    apps: vec![AppConfig::minimal("web", "./srv")],
1900                },
1901            },
1902        ];
1903        insta::assert_json_snapshot!("request_wire_v3", requests);
1904    }
1905
1906    #[test]
1907    fn reply_wire_snapshots() {
1908        let replies = vec![
1909            Reply {
1910                id: 1,
1911                result: Ok(Response::Pong),
1912            },
1913            Reply {
1914                id: 2,
1915                result: Ok(Response::Flock(vec![sample_info()])),
1916            },
1917            Reply {
1918                id: 3,
1919                result: Err(RpcError {
1920                    code: RpcErrorCode::NotFound,
1921                    message: "no sheep matches `web`".to_string(),
1922                    daemon_version: None,
1923                }),
1924            },
1925            // `ActionReply` is not a `ProcessInfo`. `Replied` is the
1926            // struct-shaped `ActionOutcome` variant and so the one worth
1927            // pinning.
1928            Reply {
1929                id: 4,
1930                result: Ok(Response::Triggered(vec![ActionReply {
1931                    id: 3,
1932                    name: "web".to_string(),
1933                    outcome: ActionOutcome::Replied {
1934                        body: "ok".to_string(),
1935                    },
1936                }])),
1937            },
1938            // The only struct-shaped `Response` variant; every other one is
1939            // a newtype over a `Vec` or a unit, both proven above.
1940            Reply {
1941                id: 5,
1942                result: Ok(Response::RollSaved {
1943                    path: "/home/ada/.shep/flock.json".to_string(),
1944                    apps: 2,
1945                }),
1946            },
1947            // The present `dog` marker; `sample_info()` pins the absent one.
1948            // `Adopted` because it is the variant carrying a payload.
1949            Reply {
1950                id: 6,
1951                result: Ok(Response::Flock(vec![ProcessInfo {
1952                    id: 7,
1953                    name: "otel".to_string(),
1954                    dog: Some(DogSource::Adopted {
1955                        path: "/usr/local/bin/shep-otel".to_string(),
1956                    }),
1957                    ..sample_info()
1958                }])),
1959            },
1960            // The section crosses the wire as text, never a typed structure.
1961            Reply {
1962                id: 7,
1963                result: Ok(Response::DogSection {
1964                    toml: "port = 9615\n".to_string().into(),
1965                }),
1966            },
1967            // The only `Response` variant carrying a bare `ProcessInfo`
1968            // rather than a `Vec`: `enable` starts exactly one dog.
1969            Reply {
1970                id: 8,
1971                result: Ok(Response::DogStarted(ProcessInfo {
1972                    id: 4,
1973                    name: "metrics".to_string(),
1974                    dog: Some(DogSource::BuiltIn),
1975                    ..sample_info()
1976                })),
1977            },
1978            // Each row below carries the smallest body that shows its wire
1979            // shape: the tag is what is being pinned. `Deleted` is a
1980            // `Vec<u32>`; `Subscribed` and `ShuttingDown` carry nothing.
1981            Reply {
1982                id: 9,
1983                result: Ok(Response::Described(vec![])),
1984            },
1985            Reply {
1986                id: 10,
1987                result: Ok(Response::Started(vec![])),
1988            },
1989            Reply {
1990                id: 11,
1991                result: Ok(Response::Stopped(vec![])),
1992            },
1993            Reply {
1994                id: 12,
1995                result: Ok(Response::Restarted(vec![])),
1996            },
1997            Reply {
1998                id: 13,
1999                result: Ok(Response::Reloading(vec![])),
2000            },
2001            Reply {
2002                id: 14,
2003                result: Ok(Response::Deleted(vec![7, 8])),
2004            },
2005            Reply {
2006                id: 15,
2007                result: Ok(Response::Reopened(vec![])),
2008            },
2009            Reply {
2010                id: 16,
2011                result: Ok(Response::Flushed(vec![])),
2012            },
2013            Reply {
2014                id: 17,
2015                result: Ok(Response::Mustered(vec![])),
2016            },
2017            Reply {
2018                id: 18,
2019                result: Ok(Response::Subscribed),
2020            },
2021            Reply {
2022                id: 19,
2023                result: Ok(Response::ShuttingDown),
2024            },
2025            // `Signalled`, mirroring the `Triggered` row: one row per
2026            // `SignalOutcome` variant, so no tag is left unproven.
2027            Reply {
2028                id: 20,
2029                result: Ok(Response::Signalled(vec![
2030                    SignalReply {
2031                        id: 1,
2032                        name: "web".to_string(),
2033                        outcome: SignalOutcome::Delivered,
2034                    },
2035                    SignalReply {
2036                        id: 2,
2037                        name: "web".to_string(),
2038                        outcome: SignalOutcome::NotRunning,
2039                    },
2040                    SignalReply {
2041                        id: 3,
2042                        name: "api".to_string(),
2043                        outcome: SignalOutcome::Failed {
2044                            reason: "no such process".to_string(),
2045                        },
2046                    },
2047                ])),
2048            },
2049            Reply {
2050                id: 21,
2051                result: Ok(Response::Scaled(vec![sample_info()])),
2052            },
2053            // `SentLine`, mirroring the `Signalled` row: one row per
2054            // `LineOutcome` variant.
2055            Reply {
2056                id: 22,
2057                result: Ok(Response::SentLine(vec![
2058                    LineReply {
2059                        id: 1,
2060                        name: "repl".to_string(),
2061                        outcome: LineOutcome::Sent,
2062                    },
2063                    LineReply {
2064                        id: 2,
2065                        name: "web".to_string(),
2066                        outcome: LineOutcome::NoStdin,
2067                    },
2068                    LineReply {
2069                        id: 3,
2070                        name: "stuck".to_string(),
2071                        outcome: LineOutcome::NotWritten {
2072                            reason: "the app did not read its stdin within 2s".to_string(),
2073                        },
2074                    },
2075                ])),
2076            },
2077            // A walked lamb tree; every other row pins the `null` shape.
2078            Reply {
2079                id: 23,
2080                result: Ok(Response::Described(vec![
2081                    ProcessInfo::builder(3, "web", ProcStatus::Online)
2082                        .pid(Some(4242))
2083                        .lambs(Some(vec![Lamb::new(4243, "node"), Lamb::new(4244, "sh")]))
2084                        .build(),
2085                ])),
2086            },
2087            // The killed-by-signal shape of `last_exit`; every row above pins
2088            // the exited-normally one. `SIGTERM`'s raw number, since this
2089            // crate carries no name for it.
2090            Reply {
2091                id: 24,
2092                result: Ok(Response::Flock(vec![
2093                    ProcessInfo::builder(5, "worker", ProcStatus::Stopped)
2094                        .restarts(1)
2095                        .last_exit(Some(ExitInfo {
2096                            code: None,
2097                            signal: Some(15),
2098                        }))
2099                        .build(),
2100                ])),
2101            },
2102            // The one row that pins a smit on the wire; `sample_info()` carries
2103            // none.
2104            Reply {
2105                id: 25,
2106                result: Ok(Response::SmitPainted(vec![
2107                    ProcessInfo::builder(3, "web", ProcStatus::Online)
2108                        .pid(Some(4242))
2109                        .smit(Some("\u{25b2} main@a1b2c3".to_string()))
2110                        .build(),
2111                ])),
2112            },
2113            // A sheep drifting in one field and a sheep drifting in several.
2114            // `env` is one of them: the name travels and the value never does.
2115            Reply {
2116                id: 26,
2117                result: Ok(Response::Drifted(vec![
2118                    SheepDrift::new("web", vec!["cwd".to_string()]),
2119                    SheepDrift::new(
2120                        "api",
2121                        vec!["args".to_string(), "env".to_string(), "script".to_string()],
2122                    ),
2123                ])),
2124            },
2125            // The present shape of `instance`; every row above pins its absence.
2126            Reply {
2127                id: 27,
2128                result: Ok(Response::Flock(vec![
2129                    ProcessInfo::builder(9, "web", ProcStatus::Online)
2130                        .pid(Some(5150))
2131                        .instance(Some(2))
2132                        .build(),
2133                ])),
2134            },
2135            // Both shapes of the handover answer; the difference between them
2136            // is a `null`.
2137            Reply {
2138                id: 28,
2139                result: Ok(Response::HandoverFitness { refusal: None }),
2140            },
2141            Reply {
2142                id: 29,
2143                result: Ok(Response::HandoverFitness {
2144                    refusal: Some("sheep 'web' has a shepherd channel".to_string()),
2145                }),
2146            },
2147            // Both lists non-empty and different: the two carry the same wire
2148            // shape.
2149            Reply {
2150                id: 30,
2151                result: Ok(Response::DogStaleness {
2152                    stale: vec!["metrics".to_string()],
2153                    pending: vec!["bark".to_string()],
2154                }),
2155            },
2156            // A dog whose process is up and which has never answered this
2157            // shepherd. `dog_stale: false` is the silence still being waited
2158            // out; the row below is the one it has given up on.
2159            Reply {
2160                id: 31,
2161                result: Ok(Response::Flock(vec![
2162                    ProcessInfo::builder(10, "log-rotate", ProcStatus::Online)
2163                        .pid(Some(208_341))
2164                        .dog(Some(DogSource::Adopted {
2165                            path: "/usr/local/bin/shep-log-rotate".to_string(),
2166                        }))
2167                        .handshook(Some(false))
2168                        .dog_stale(Some(false))
2169                        .build(),
2170                ])),
2171            },
2172            Reply {
2173                id: 32,
2174                result: Ok(Response::Flock(vec![
2175                    ProcessInfo::builder(10, "log-rotate", ProcStatus::Online)
2176                        .pid(Some(208_341))
2177                        .dog(Some(DogSource::Adopted {
2178                            path: "/usr/local/bin/shep-log-rotate".to_string(),
2179                        }))
2180                        .handshook(Some(false))
2181                        .dog_stale(Some(true))
2182                        .build(),
2183                ])),
2184            },
2185            // Three entries, one per shape a load produces: applied, pending,
2186            // refused. `env` is a pending name on purpose: the name travels
2187            // and the value never does.
2188            Reply {
2189                id: 32,
2190                result: Ok(Response::Applied(vec![
2191                    SheepApplied::new("web", vec!["max_memory".to_string()], Vec::new(), None),
2192                    SheepApplied::new(
2193                        "api",
2194                        Vec::new(),
2195                        vec!["args".to_string(), "env".to_string()],
2196                        None,
2197                    ),
2198                    SheepApplied::new(
2199                        "worker",
2200                        Vec::new(),
2201                        Vec::new(),
2202                        Some("worker is not registered".to_string()),
2203                    ),
2204                ])),
2205            },
2206            // `Added`'s tag, all a fixture can prove for a `Vec<ProcessInfo>`
2207            // variant. Down here because every id in this vector is
2208            // hand-written.
2209            Reply {
2210                id: 33,
2211                result: Ok(Response::Added(vec![])),
2212            },
2213        ];
2214        insta::assert_json_snapshot!("reply_wire_v3", replies);
2215    }
2216
2217    /// Asserts on the JSON, not the struct: a `Vec<String>` cannot say which
2218    /// of the two a string is, so a build carrying a value would typecheck.
2219    #[test]
2220    fn a_sheep_applied_carries_names_and_never_values() {
2221        let applied = SheepApplied::new(
2222            "web",
2223            vec!["cwd".to_string()],
2224            vec!["env".to_string()],
2225            None,
2226        );
2227        let json = serde_json::to_string(&applied).unwrap();
2228        assert!(json.contains("\"env\""), "the NAME travels: {json}");
2229        assert!(
2230            !json.contains("DATABASE_URL"),
2231            "and no value ever does: {json}"
2232        );
2233    }
2234
2235    #[test]
2236    fn a_sheep_applied_debug_prints_the_names_it_was_given() {
2237        let applied = SheepApplied::new("web", vec!["cwd".to_string()], Vec::new(), None);
2238        assert_eq!(
2239            format!("{applied:?}"),
2240            "SheepApplied { name: \"web\", applied: [\"cwd\"], pending: [], refused: None }"
2241        );
2242    }
2243
2244    #[test]
2245    fn a_process_info_without_a_smit_key_still_deserializes() {
2246        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}"#;
2247        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
2248        assert_eq!(info.smit, None);
2249    }
2250
2251    /// The fixture is a dog's row, where `None` means "render this as it
2252    /// rendered before the field existed", never "never handshaken".
2253    #[test]
2254    fn a_process_info_without_a_handshook_key_still_deserializes() {
2255        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}"#;
2256        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
2257        assert_eq!(info.handshook, None);
2258        assert_eq!(info.dog, Some(DogSource::BuiltIn));
2259    }
2260
2261    /// The fixture carries `handshook: false`, the case that matters: `None`
2262    /// is "no verdict to report", never "it has not given up".
2263    #[test]
2264    fn a_process_info_without_a_dog_stale_key_still_deserializes() {
2265        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}"#;
2266        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
2267        assert_eq!(info.dog_stale, None);
2268        assert_eq!(info.handshook, Some(false));
2269    }
2270
2271    /// A dog written in another language speaks this wire directly and never
2272    /// runs `FromStr`.
2273    #[test]
2274    fn a_smit_is_validated_when_it_is_deserialized_not_only_when_parsed() {
2275        for bad in [
2276            r#""\u001b[2Jgone""#.to_string(),                    // an escape
2277            r#""a\nb""#.to_string(),                             // a newline
2278            r#""""#.to_string(),                                 // empty
2279            r#""   ""#.to_string(),                              // whitespace
2280            format!(r#""{}""#, "x".repeat(Smit::MAX_CHARS + 1)), // too long
2281        ] {
2282            assert!(
2283                serde_json::from_str::<Smit>(&bad).is_err(),
2284                "a daemon must refuse this on the wire: {bad}"
2285            );
2286        }
2287        assert!(serde_json::from_str::<Smit>(r#""\u25b2 main@a1b2c3""#).is_ok());
2288    }
2289
2290    /// The hand-written `Deserialize` agrees with the derived `Serialize`
2291    /// only while the serialize side stays transparent.
2292    #[test]
2293    fn a_smit_travels_as_a_bare_string() {
2294        let smit: Smit = "\u{25b2} main@a1b2c3".parse().expect("valid");
2295        let json = serde_json::to_string(&smit).unwrap();
2296        assert_eq!(json, "\"\u{25b2} main@a1b2c3\"");
2297        assert_eq!(serde_json::from_str::<Smit>(&json).unwrap(), smit);
2298    }
2299
2300    #[test]
2301    fn a_smit_is_capped_in_characters_not_bytes() {
2302        let cjk = "\u{7f8a}".repeat(Smit::MAX_CHARS);
2303        assert_eq!(cjk.len(), Smit::MAX_CHARS * 3);
2304        assert!(cjk.parse::<Smit>().is_ok(), "{cjk}");
2305        assert_eq!(
2306            "x".repeat(Smit::MAX_CHARS + 1).parse::<Smit>(),
2307            Err(SmitError::TooLong {
2308                chars: Smit::MAX_CHARS + 1
2309            })
2310        );
2311    }
2312
2313    #[test]
2314    fn a_smit_is_stored_exactly_as_it_arrived() {
2315        let padded: Smit = "  main@a1b2c3  ".parse().expect("valid");
2316        assert_eq!(padded.as_str(), "  main@a1b2c3  ");
2317        assert_eq!(padded.to_string(), "  main@a1b2c3  ");
2318    }
2319
2320    #[test]
2321    fn v1_fixture_still_deserializes() {
2322        // Committed byte fixture from protocol v1. If this breaks, bump
2323        // PROTOCOL_VERSION and record it in the CHANGELOG.
2324        let fixture = r#"{"id":7,"deadline_ms":null,"body":{"kind":"stop","selector":{"kind":"name","value":"web"}}}"#;
2325        let env: Envelope = serde_json::from_str(fixture).unwrap();
2326        assert_eq!(env.id, 7);
2327        assert!(matches!(
2328            env.body,
2329            Request::Stop { selector: SelectorSpec::Name(ref n) } if n == "web"
2330        ));
2331    }
2332
2333    #[test]
2334    fn hello_handshake_shape() {
2335        let hello = Hello {
2336            client_version: "0.1.0".to_string(),
2337            protocol: PROTOCOL_VERSION,
2338            dog_name: None,
2339        };
2340        let json = serde_json::to_string(&hello).unwrap();
2341        assert_eq!(json, r#"{"client_version":"0.1.0","protocol":3}"#);
2342    }
2343
2344    #[test]
2345    fn a_dogs_hello_names_the_dog_and_nothing_elses_does() {
2346        let dog = Hello {
2347            client_version: "0.1.0".to_string(),
2348            protocol: PROTOCOL_VERSION,
2349            dog_name: Some("metrics".to_string()),
2350        };
2351        let json = serde_json::to_string(&dog).unwrap();
2352        assert_eq!(
2353            json,
2354            r#"{"client_version":"0.1.0","protocol":3,"dog_name":"metrics"}"#
2355        );
2356        assert_eq!(serde_json::from_str::<Hello>(&json).unwrap(), dog);
2357    }
2358
2359    /// `Hello` is the version-negotiation frame, so `deny_unknown_fields`
2360    /// here would refuse a newer client before `protocol` is read, leaving
2361    /// neither peer able to report the skew.
2362    #[test]
2363    fn a_hello_without_a_dog_name_still_parses() {
2364        let fixture = r#"{"client_version":"0.1.14","protocol":2}"#;
2365        let hello: Hello = serde_json::from_str(fixture).unwrap();
2366        assert_eq!(hello.protocol, 2);
2367        assert_eq!(hello.dog_name, None);
2368
2369        // The other direction: an older daemon ignores a key it does not
2370        // know. `unknown_to_an_older_daemon` stands in for `dog_name`.
2371        let newer = r#"{"client_version":"9.9.9","protocol":2,"dog_name":"metrics","unknown_to_an_older_daemon":true}"#;
2372        let hello: Hello = serde_json::from_str(newer).unwrap();
2373        assert_eq!(hello.protocol, 2);
2374        assert_eq!(hello.dog_name.as_deref(), Some("metrics"));
2375    }
2376
2377    #[test]
2378    fn hello_reply_carries_typed_skew_error() {
2379        let refusal: HelloReply = Err(RpcError {
2380            code: RpcErrorCode::ProtocolMismatch,
2381            message: "daemon speaks protocol 1, client sent 2".to_string(),
2382            daemon_version: None,
2383        });
2384        let json = serde_json::to_string(&refusal).unwrap();
2385        assert_eq!(
2386            json,
2387            r#"{"Err":{"code":"protocol_mismatch","message":"daemon speaks protocol 1, client sent 2"}}"#
2388        );
2389        let back: HelloReply = serde_json::from_str(&json).unwrap();
2390        assert_eq!(back, refusal);
2391    }
2392
2393    #[test]
2394    fn v1_reply_fixture_still_deserializes() {
2395        // Committed byte fixture, protocol v1.
2396        let ok = r#"{"id":1,"result":{"Ok":{"kind":"pong"}}}"#;
2397        let reply: Reply = serde_json::from_str(ok).unwrap();
2398        assert!(matches!(reply.result, Ok(Response::Pong)));
2399        let err = r#"{"id":2,"result":{"Err":{"code":"not_found","message":"no sheep"}}}"#;
2400        let reply: Reply = serde_json::from_str(err).unwrap();
2401        assert_eq!(reply.result.unwrap_err().code, RpcErrorCode::NotFound);
2402    }
2403
2404    #[test]
2405    fn v1_hello_ack_fixture_still_deserializes() {
2406        let fixture = r#"{"Ok":{"daemon_version":"0.1.0","protocol":1,"pid":4242}}"#;
2407        let ack: HelloReply = serde_json::from_str(fixture).unwrap();
2408        assert_eq!(ack.unwrap().pid, 4242);
2409    }
2410
2411    #[test]
2412    fn v1_process_info_without_stats_still_deserializes() {
2413        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"}"#;
2414        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
2415        assert_eq!(info.cpu_percent, None);
2416        assert_eq!(info.memory_bytes, None);
2417    }
2418
2419    #[test]
2420    fn v1_process_info_without_log_paths_still_deserializes() {
2421        // Committed byte fixture from before `out_file`/`err_file` existed.
2422        let fixture = r#"{"id":3,"name":"web","status":"online","pid":4242,"restarts":1,"uptime_ms":60000,"fold":"backend"}"#;
2423        let info: ProcessInfo = serde_json::from_str(fixture).unwrap();
2424        assert_eq!(info.id, 3);
2425        assert_eq!(info.out_file, None);
2426        assert_eq!(info.err_file, None);
2427    }
2428
2429    #[test]
2430    fn an_old_client_still_decodes_a_new_process_info() {
2431        // `ProcessInfo` carries no `deny_unknown_fields`, unlike the config
2432        // types in `crate::config`, so extra keys are ignored.
2433        #[derive(Deserialize)]
2434        struct V1ProcessInfo {
2435            id: u32,
2436            fold: Option<String>,
2437        }
2438
2439        let current = serde_json::to_string(&sample_info()).unwrap();
2440        let old: V1ProcessInfo = serde_json::from_str(&current).unwrap();
2441        assert_eq!(old.id, 3);
2442        assert_eq!(old.fold.as_deref(), Some("backend"));
2443    }
2444
2445    #[test]
2446    fn an_rpc_error_without_a_daemon_version_serializes_exactly_as_before() {
2447        // `skip_serializing_if` is what makes the field free: no
2448        // `"daemon_version":null` key for an older client to ignore.
2449        let plain = RpcError {
2450            code: RpcErrorCode::NotFound,
2451            message: "no sheep".to_string(),
2452            daemon_version: None,
2453        };
2454        assert_eq!(
2455            serde_json::to_string(&plain).unwrap(),
2456            r#"{"code":"not_found","message":"no sheep"}"#
2457        );
2458    }
2459
2460    #[test]
2461    fn a_v1_rpc_error_fixture_deserializes_with_no_daemon_version() {
2462        let fixture =
2463            r#"{"code":"protocol_mismatch","message":"daemon speaks protocol 1, client sent 2"}"#;
2464        let err: RpcError = serde_json::from_str(fixture).unwrap();
2465        assert_eq!(err.code, RpcErrorCode::ProtocolMismatch);
2466        assert_eq!(err.daemon_version, None);
2467    }
2468
2469    #[test]
2470    fn an_old_client_ignores_an_rpc_error_field_it_has_never_seen() {
2471        // `RpcError` carries no `deny_unknown_fields`, so an optional field
2472        // may be added without moving `PROTOCOL_VERSION`.
2473        #[derive(Deserialize)]
2474        struct OldRpcError {
2475            code: RpcErrorCode,
2476            message: String,
2477        }
2478
2479        let current = serde_json::to_string(&RpcError {
2480            code: RpcErrorCode::ProtocolMismatch,
2481            message: "daemon speaks protocol 1, client sent 2".to_string(),
2482            daemon_version: Some("0.1.16".to_string()),
2483        })
2484        .unwrap();
2485        let old: OldRpcError = serde_json::from_str(&current).expect("must tolerate");
2486        assert_eq!(old.code, RpcErrorCode::ProtocolMismatch);
2487        assert_eq!(old.message, "daemon speaks protocol 1, client sent 2");
2488    }
2489
2490    #[test]
2491    fn deadline_exceeded_code_serializes_snake_case() {
2492        assert_eq!(
2493            serde_json::to_string(&RpcErrorCode::DeadlineExceeded).unwrap(),
2494            "\"deadline_exceeded\""
2495        );
2496        assert_eq!(
2497            serde_json::from_str::<RpcErrorCode>("\"deadline_exceeded\"").unwrap(),
2498            RpcErrorCode::DeadlineExceeded
2499        );
2500    }
2501
2502    #[test]
2503    fn action_outcome_kinds_serialize_snake_case_and_round_trip() {
2504        // The shared snapshots exercise only `Replied`, the struct-shaped
2505        // variant.
2506        let cases = [
2507            (
2508                ActionOutcome::Replied {
2509                    body: "pong".to_string(),
2510                },
2511                r#"{"kind":"replied","body":"pong"}"#,
2512            ),
2513            (ActionOutcome::NoChannel, r#"{"kind":"no_channel"}"#),
2514            (ActionOutcome::Skipped, r#"{"kind":"skipped"}"#),
2515            (ActionOutcome::TimedOut, r#"{"kind":"timed_out"}"#),
2516        ];
2517        for (outcome, wire) in cases {
2518            assert_eq!(
2519                serde_json::to_string(&outcome).unwrap(),
2520                wire,
2521                "{outcome:?}"
2522            );
2523            assert_eq!(
2524                serde_json::from_str::<ActionOutcome>(wire).unwrap(),
2525                outcome
2526            );
2527        }
2528    }
2529
2530    #[test]
2531    fn save_roll_serializes_snake_case_with_its_payload_under_data() {
2532        assert_eq!(
2533            serde_json::to_string(&Request::SaveRoll).unwrap(),
2534            r#"{"kind":"save_roll"}"#
2535        );
2536        let reply = Response::RollSaved {
2537            path: "/tmp/flock.json".to_string(),
2538            apps: 3,
2539        };
2540        let wire = r#"{"kind":"roll_saved","data":{"path":"/tmp/flock.json","apps":3}}"#;
2541        assert_eq!(serde_json::to_string(&reply).unwrap(), wire);
2542        assert_eq!(serde_json::from_str::<Response>(wire).unwrap(), reply);
2543    }
2544
2545    /// The listing is empty on purpose: `reply_wire_snapshots` pins the row
2546    /// field by field.
2547    #[test]
2548    fn muster_serializes_snake_case_with_its_listing_under_data() {
2549        assert_eq!(
2550            serde_json::to_string(&Request::Muster).unwrap(),
2551            r#"{"kind":"muster"}"#
2552        );
2553        let reply = Response::Mustered(Vec::new());
2554        let wire = r#"{"kind":"mustered","data":[]}"#;
2555        assert_eq!(serde_json::to_string(&reply).unwrap(), wire);
2556        assert_eq!(serde_json::from_str::<Response>(wire).unwrap(), reply);
2557    }
2558
2559    #[test]
2560    fn the_dog_verbs_serialize_snake_case_with_their_payloads_under_data() {
2561        assert_eq!(
2562            serde_json::to_string(&Request::DogConfig {
2563                name: "bark".to_string()
2564            })
2565            .unwrap(),
2566            r#"{"kind":"dog_config","name":"bark"}"#
2567        );
2568        assert_eq!(
2569            serde_json::to_string(&Request::DisableDog {
2570                name: "bark".to_string()
2571            })
2572            .unwrap(),
2573            r#"{"kind":"disable_dog","name":"bark"}"#
2574        );
2575        let section = Response::DogSection {
2576            toml: "port = 9615\n".to_string().into(),
2577        };
2578        let wire = r#"{"kind":"dog_section","data":{"toml":"port = 9615\n"}}"#;
2579        assert_eq!(serde_json::to_string(&section).unwrap(), wire);
2580        assert_eq!(serde_json::from_str::<Response>(wire).unwrap(), section);
2581    }
2582
2583    #[test]
2584    fn dog_section_toml_debug_does_not_leak() {
2585        // A dog's section routinely holds webhook credentials. Pinned as an
2586        // exact string, so a `#[derive(Debug)]` on `DogSectionToml` fails
2587        // here.
2588        let toml: DogSectionToml =
2589            "webhook_url = \"https://discord.com/api/webhooks/1/super-secret-token\"\n"
2590                .to_string()
2591                .into();
2592        assert_eq!(format!("{toml:?}"), "DogSectionToml(<70 bytes>)");
2593
2594        let response = Response::DogSection { toml };
2595        assert_eq!(
2596            format!("{response:?}"),
2597            "DogSection { toml: DogSectionToml(<70 bytes>) }"
2598        );
2599    }
2600
2601    /// The fixture cannot agree under either candidate order: by id it is
2602    /// `web/1, api/2, web/0`, by name `api, web, web`. The two `web` rows
2603    /// are the tiebreak half, seeded out of order.
2604    #[test]
2605    fn a_listing_sorts_by_name_then_by_id() {
2606        let mut listing = vec![
2607            ProcessInfo::builder(1, "web", ProcStatus::Online).build(),
2608            ProcessInfo::builder(2, "api", ProcStatus::Online).build(),
2609            ProcessInfo::builder(0, "web", ProcStatus::Online).build(),
2610        ];
2611        sort_flock(&mut listing);
2612
2613        let seen: Vec<(&str, u32)> = listing
2614            .iter()
2615            .map(|info| (info.name.as_str(), info.id))
2616            .collect();
2617        assert_eq!(
2618            seen,
2619            vec![("api", 2), ("web", 0), ("web", 1)],
2620            "name first, then id inside a name"
2621        );
2622    }
2623
2624    #[test]
2625    fn an_instance_slot_survives_a_round_trip_and_defaults_to_absent() {
2626        let with = ProcessInfo::builder(1, "web", ProcStatus::Online)
2627            .instance(Some(2))
2628            .build();
2629        assert_eq!(with.instance, Some(2));
2630
2631        let without = ProcessInfo::builder(1, "web", ProcStatus::Online).build();
2632        assert_eq!(
2633            without.instance, None,
2634            "a row nobody set a slot on says so, rather than claiming slot 0"
2635        );
2636    }
2637
2638    #[test]
2639    fn a_reply_from_a_daemon_without_the_field_deserializes_as_absent() {
2640        let json = r#"{"id":1,"name":"web","status":"online","pid":null,
2641            "restarts":0,"uptime_ms":0,"fold":null,"out_file":null,
2642            "err_file":null,"cpu_percent":null,"memory_bytes":null,"dog":null,
2643            "lambs":null,"last_exit":null,"smit":null}"#;
2644        let info: ProcessInfo = serde_json::from_str(json).expect("older reply still parses");
2645        assert_eq!(info.instance, None);
2646    }
2647
2648    #[test]
2649    fn sort_flock_orders_by_slot_before_id() {
2650        // A reload gave slot 0 a fresh, higher id. Slot order must still win.
2651        let mut listing = vec![
2652            ProcessInfo::builder(9, "web", ProcStatus::Online)
2653                .instance(Some(0))
2654                .build(),
2655            ProcessInfo::builder(2, "web", ProcStatus::Online)
2656                .instance(Some(1))
2657                .build(),
2658        ];
2659        sort_flock(&mut listing);
2660        assert_eq!(
2661            listing.iter().map(|i| i.id).collect::<Vec<_>>(),
2662            vec![9, 2],
2663            "slot 0 leads even though its id is higher"
2664        );
2665    }
2666
2667    #[test]
2668    fn sort_flock_falls_back_to_id_when_no_row_carries_a_slot() {
2669        let mut listing = vec![
2670            ProcessInfo::builder(5, "web", ProcStatus::Online).build(),
2671            ProcessInfo::builder(3, "web", ProcStatus::Online).build(),
2672        ];
2673        sort_flock(&mut listing);
2674        assert_eq!(
2675            listing.iter().map(|i| i.id).collect::<Vec<_>>(),
2676            vec![3, 5],
2677            "an older daemon's listing sorts exactly as it does today"
2678        );
2679    }
2680}