1use std::collections::BTreeMap;
14
15use serde::{Deserialize, Serialize};
16
17pub const CHANNEL_VERSION: &str = "1";
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "lowercase")]
33pub enum MetricType {
34 Gauge,
36 Counter,
38}
39
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43#[serde(tag = "kind", rename_all = "kebab-case")]
44pub enum ChildMessage {
45 Ready,
47 Metric {
53 name: String,
55 value: f64,
57 #[serde(rename = "type", skip_serializing_if = "Option::is_none", default)]
63 metric_type: Option<MetricType>,
64 #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
67 labels: BTreeMap<String, String>,
68 #[serde(skip_serializing_if = "Option::is_none", default)]
70 help: Option<String>,
71 },
72 ActionReply {
74 action: String,
76 body: String,
78 #[serde(skip_serializing_if = "Option::is_none", default)]
82 id: Option<u64>,
83 },
84 LambLabel {
89 pid: u32,
91 label: LambLabel,
93 },
94 Note {
99 name: String,
101 value: f64,
103 },
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(try_from = "String", into = "String")]
114pub struct LambLabel(String);
115
116impl LambLabel {
117 pub const MAX_CHARS: usize = 64;
121
122 pub fn new(label: impl Into<String>) -> Result<Self, LambLabelError> {
131 let label = label.into();
132 let chars = label.chars().count();
133 if chars > Self::MAX_CHARS {
134 return Err(LambLabelError::TooLong { chars });
135 }
136 if label.chars().any(char::is_control) {
137 return Err(LambLabelError::ControlCharacter);
138 }
139 Ok(Self(label))
140 }
141
142 #[must_use]
144 pub fn as_str(&self) -> &str {
145 &self.0
146 }
147
148 #[must_use]
150 pub fn is_empty(&self) -> bool {
151 self.0.is_empty()
152 }
153}
154
155impl TryFrom<String> for LambLabel {
156 type Error = LambLabelError;
157
158 fn try_from(label: String) -> Result<Self, Self::Error> {
159 Self::new(label)
160 }
161}
162
163impl From<LambLabel> for String {
164 fn from(label: LambLabel) -> Self {
165 label.0
166 }
167}
168
169#[non_exhaustive]
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub enum LambLabelError {
174 TooLong {
176 chars: usize,
178 },
179 ControlCharacter,
182}
183
184impl core::fmt::Display for LambLabelError {
185 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
186 match self {
187 Self::TooLong { chars } => write!(
188 f,
189 "a lamb label holds at most {} characters, and this one has {chars}",
190 LambLabel::MAX_CHARS
191 ),
192 Self::ControlCharacter => f.write_str("a lamb label cannot hold a control character"),
193 }
194 }
195}
196
197impl core::error::Error for LambLabelError {}
198
199#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
202#[serde(tag = "kind", rename_all = "kebab-case")]
203pub enum ShepherdMessage {
204 Shutdown,
206 Action {
208 name: String,
210 #[serde(skip_serializing_if = "Option::is_none", default)]
222 params: Option<String>,
223 id: u64,
231 },
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
242 fn ready_wire_fixture_round_trips() {
243 let fixture = r#"{"kind":"ready"}"#;
244 assert_eq!(
245 serde_json::from_str::<ChildMessage>(fixture).unwrap(),
246 ChildMessage::Ready
247 );
248 assert_eq!(
249 serde_json::to_string(&ChildMessage::Ready).unwrap(),
250 fixture
251 );
252 }
253
254 #[test]
255 fn metric_wire_fixture_round_trips() {
256 let fixture = r#"{"kind":"metric","name":"rps","value":42.0}"#;
257 let msg = ChildMessage::Metric {
258 name: "rps".to_string(),
259 value: 42.0,
260 metric_type: None,
261 labels: BTreeMap::new(),
262 help: None,
263 };
264 assert_eq!(serde_json::from_str::<ChildMessage>(fixture).unwrap(), msg);
265 assert_eq!(serde_json::to_string(&msg).unwrap(), fixture);
266 }
267
268 #[test]
270 fn a_metric_with_type_labels_and_help_round_trips() {
271 let fixture = concat!(
272 r#"{"kind":"metric","name":"queue_depth","value":3.0,"type":"counter","#,
273 r#""labels":{"model":"qwen"},"help":"Requests waiting for the lease."}"#,
274 );
275 let msg = ChildMessage::Metric {
276 name: "queue_depth".to_string(),
277 value: 3.0,
278 metric_type: Some(MetricType::Counter),
279 labels: [("model".to_string(), "qwen".to_string())].into(),
280 help: Some("Requests waiting for the lease.".to_string()),
281 };
282 assert_eq!(serde_json::from_str::<ChildMessage>(fixture).unwrap(), msg);
283 assert_eq!(serde_json::to_string(&msg).unwrap(), fixture);
284 }
285
286 #[test]
287 fn a_gauge_type_round_trips_as_its_own_spelling() {
288 let fixture = r#"{"kind":"metric","name":"rps","value":1.0,"type":"gauge"}"#;
289 let msg = ChildMessage::Metric {
290 name: "rps".to_string(),
291 value: 1.0,
292 metric_type: Some(MetricType::Gauge),
293 labels: BTreeMap::new(),
294 help: None,
295 };
296 assert_eq!(serde_json::from_str::<ChildMessage>(fixture).unwrap(), msg);
297 assert_eq!(serde_json::to_string(&msg).unwrap(), fixture);
298 }
299
300 #[test]
302 fn an_unknown_metric_type_fails_to_decode() {
303 for frame in [
304 r#"{"kind":"metric","name":"rps","value":1.0,"type":"histogram"}"#,
305 r#"{"kind":"metric","name":"rps","value":1.0,"type":"Counter"}"#,
306 ] {
307 assert!(
308 serde_json::from_str::<ChildMessage>(frame).is_err(),
309 "{frame} decoded"
310 );
311 }
312 }
313
314 #[test]
315 fn an_action_reply_without_an_id_round_trips() {
316 let fixture = r#"{"kind":"action-reply","action":"gc","body":"ok"}"#;
317 let msg = ChildMessage::ActionReply {
318 action: "gc".to_string(),
319 body: "ok".to_string(),
320 id: None,
321 };
322 assert_eq!(serde_json::from_str::<ChildMessage>(fixture).unwrap(), msg);
323 assert_eq!(serde_json::to_string(&msg).unwrap(), fixture);
324 }
325
326 #[test]
327 fn an_action_reply_with_an_echoed_id_round_trips() {
328 let fixture = r#"{"kind":"action-reply","action":"gc","body":"ok","id":7}"#;
329 let msg = ChildMessage::ActionReply {
330 action: "gc".to_string(),
331 body: "ok".to_string(),
332 id: Some(7),
333 };
334 assert_eq!(serde_json::from_str::<ChildMessage>(fixture).unwrap(), msg);
335 assert_eq!(serde_json::to_string(&msg).unwrap(), fixture);
336 }
337
338 #[test]
339 fn shutdown_wire_fixture_round_trips() {
340 let fixture = r#"{"kind":"shutdown"}"#;
341 assert_eq!(
342 serde_json::from_str::<ShepherdMessage>(fixture).unwrap(),
343 ShepherdMessage::Shutdown
344 );
345 assert_eq!(
346 serde_json::to_string(&ShepherdMessage::Shutdown).unwrap(),
347 fixture
348 );
349 }
350
351 #[test]
353 fn an_action_carries_its_id_with_or_without_params() {
354 let bare = r#"{"kind":"action","name":"gc","id":7}"#;
355 let bare_msg = ShepherdMessage::Action {
356 name: "gc".to_string(),
357 params: None,
358 id: 7,
359 };
360 assert_eq!(serde_json::to_string(&bare_msg).unwrap(), bare);
361 assert_eq!(
362 serde_json::from_str::<ShepherdMessage>(bare).unwrap(),
363 bare_msg
364 );
365
366 let with_params = r#"{"kind":"action","name":"set-log-level","params":"debug","id":8}"#;
367 let with_params_msg = ShepherdMessage::Action {
368 name: "set-log-level".to_string(),
369 params: Some("debug".to_string()),
370 id: 8,
371 };
372 assert_eq!(
373 serde_json::to_string(&with_params_msg).unwrap(),
374 with_params
375 );
376 assert_eq!(
377 serde_json::from_str::<ShepherdMessage>(with_params).unwrap(),
378 with_params_msg
379 );
380 }
381
382 #[test]
383 fn a_lamb_label_round_trips() {
384 let fixture = r#"{"kind":"lamb-label","pid":4312,"label":"worker 1"}"#;
385 let msg = ChildMessage::LambLabel {
386 pid: 4312,
387 label: LambLabel::new("worker 1").unwrap(),
388 };
389 assert_eq!(serde_json::from_str::<ChildMessage>(fixture).unwrap(), msg);
390 assert_eq!(serde_json::to_string(&msg).unwrap(), fixture);
391 }
392
393 #[test]
395 fn an_empty_lamb_label_is_on_the_wire_and_clears() {
396 let fixture = r#"{"kind":"lamb-label","pid":4312,"label":""}"#;
397 let ChildMessage::LambLabel { label, .. } =
398 serde_json::from_str::<ChildMessage>(fixture).unwrap()
399 else {
400 panic!("{fixture} decoded as another kind");
401 };
402 assert!(label.is_empty());
403 assert!(
404 serde_json::from_str::<ChildMessage>(r#"{"kind":"lamb-label","pid":4312}"#).is_err()
405 );
406 }
407
408 #[test]
410 fn a_note_round_trips_with_any_name() {
411 let fixture = r#"{"kind":"note","name":"lease.web.want.1760500000","value":1.0}"#;
412 let msg = ChildMessage::Note {
413 name: "lease.web.want.1760500000".to_string(),
414 value: 1.0,
415 };
416 assert_eq!(serde_json::from_str::<ChildMessage>(fixture).unwrap(), msg);
417 assert_eq!(serde_json::to_string(&msg).unwrap(), fixture);
418 }
419
420 #[test]
421 fn a_note_without_a_value_fails_to_decode() {
422 assert!(serde_json::from_str::<ChildMessage>(r#"{"kind":"note","name":"x"}"#).is_err());
423 }
424
425 #[test]
426 fn a_label_is_counted_in_characters_up_to_the_limit() {
427 let at_limit = "é".repeat(LambLabel::MAX_CHARS);
428 assert_eq!(LambLabel::new(at_limit.clone()).unwrap().as_str(), at_limit);
429 assert_eq!(
430 LambLabel::new(format!("{at_limit}e")),
431 Err(LambLabelError::TooLong {
432 chars: LambLabel::MAX_CHARS + 1
433 })
434 );
435 }
436
437 #[test]
438 fn a_label_with_a_control_character_is_refused_on_the_wire_too() {
439 for label in ["a\nb", "\u{1b}[2J", "tab\there", "\u{9b}31m"] {
440 assert_eq!(
441 LambLabel::new(label),
442 Err(LambLabelError::ControlCharacter),
443 "{label:?}"
444 );
445 let frame = serde_json::json!({"kind": "lamb-label", "pid": 1, "label": label});
446 assert!(
447 serde_json::from_value::<ChildMessage>(frame).is_err(),
448 "{label:?} decoded"
449 );
450 }
451 }
452
453 #[test]
454 fn a_label_error_says_the_limit_and_the_length() {
455 assert_eq!(
456 LambLabelError::TooLong { chars: 70 }.to_string(),
457 "a lamb label holds at most 64 characters, and this one has 70"
458 );
459 }
460}