1use std::sync::Arc;
4
5use derive_more::{Display, From};
6use serde::{Deserialize, Serialize};
7use serde_with::{DefaultOnError, serde_as, skip_serializing_none};
8
9use super::{AbsolutePath, Meta, apply_meta_update};
10use crate::{IntoMaybeUndefined, IntoOption, MaybeUndefined};
11
12#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Display, From)]
15#[serde(transparent)]
16#[from(forward)]
17#[non_exhaustive]
18pub struct TerminalId(pub Arc<str>);
19
20impl TerminalId {
21 #[must_use]
23 pub fn new(id: impl Into<Self>) -> Self {
24 id.into()
25 }
26}
27
28impl IntoOption<TerminalId> for &str {
29 fn into_option(self) -> Option<TerminalId> {
30 Some(TerminalId::new(self))
31 }
32}
33
34#[serde_as]
39#[skip_serializing_none]
40#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "camelCase")]
43#[non_exhaustive]
44pub struct Terminal {
45 pub terminal_id: TerminalId,
47 #[serde_as(deserialize_as = "DefaultOnError")]
53 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
54 #[serde(default)]
55 #[serde(rename = "_meta")]
56 pub meta: Option<Meta>,
57}
58
59impl Terminal {
60 #[must_use]
62 pub fn new(terminal_id: impl Into<TerminalId>) -> Self {
63 Self {
64 terminal_id: terminal_id.into(),
65 meta: None,
66 }
67 }
68
69 #[must_use]
71 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
72 self.meta = meta.into_option();
73 self
74 }
75}
76
77#[serde_as]
79#[skip_serializing_none]
80#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "camelCase")]
83#[non_exhaustive]
84pub struct TerminalOutput {
85 #[cfg_attr(feature = "schemars", schemars(extend("contentEncoding" = "base64")))]
87 pub data: String,
88 #[serde_as(deserialize_as = "DefaultOnError")]
94 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
95 #[serde(default)]
96 #[serde(rename = "_meta")]
97 pub meta: Option<Meta>,
98}
99
100impl TerminalOutput {
101 #[must_use]
103 pub fn new(data: impl Into<String>) -> Self {
104 Self {
105 data: data.into(),
106 meta: None,
107 }
108 }
109
110 #[must_use]
112 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
113 self.meta = meta.into_option();
114 self
115 }
116}
117
118#[serde_as]
123#[skip_serializing_none]
124#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
125#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
126#[serde(rename_all = "camelCase")]
127#[non_exhaustive]
128pub struct TerminalExitStatus {
129 #[serde_as(deserialize_as = "DefaultOnError")]
132 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
133 #[serde(default)]
134 pub exit_code: Option<i64>,
135 #[serde_as(deserialize_as = "DefaultOnError")]
141 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
142 #[serde(default)]
143 pub signal: Option<String>,
144 #[serde_as(deserialize_as = "DefaultOnError")]
150 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
151 #[serde(default)]
152 #[serde(rename = "_meta")]
153 pub meta: Option<Meta>,
154}
155
156impl TerminalExitStatus {
157 #[must_use]
159 pub fn new() -> Self {
160 Self::default()
161 }
162
163 #[must_use]
165 pub fn exit_code(mut self, exit_code: impl IntoOption<i64>) -> Self {
166 self.exit_code = exit_code.into_option();
167 self
168 }
169
170 #[must_use]
172 pub fn signal(mut self, signal: impl IntoOption<String>) -> Self {
173 self.signal = signal.into_option();
174 self
175 }
176
177 #[must_use]
179 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
180 self.meta = meta.into_option();
181 self
182 }
183}
184
185#[serde_as]
193#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(rename_all = "camelCase")]
196#[non_exhaustive]
197pub struct TerminalUpdate {
198 pub terminal_id: TerminalId,
200 #[serde_as(deserialize_as = "DefaultOnError")]
202 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
203 #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
204 pub command: MaybeUndefined<String>,
205 #[serde_as(deserialize_as = "DefaultOnError")]
207 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
208 #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
209 pub cwd: MaybeUndefined<AbsolutePath>,
210 #[serde_as(deserialize_as = "DefaultOnError")]
212 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
213 #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
214 pub output: MaybeUndefined<TerminalOutput>,
215 #[serde_as(deserialize_as = "DefaultOnError")]
217 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
218 #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
219 pub exit_status: MaybeUndefined<TerminalExitStatus>,
220 #[serde_as(deserialize_as = "DefaultOnError")]
225 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
226 #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
227 pub size: MaybeUndefined<TerminalSize>,
228 #[serde_as(deserialize_as = "DefaultOnError<MaybeUndefined<_>>")]
237 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
238 #[serde(
239 rename = "_meta",
240 default,
241 skip_serializing_if = "MaybeUndefined::is_undefined"
242 )]
243 pub meta: MaybeUndefined<Meta>,
244}
245
246impl TerminalUpdate {
247 #[must_use]
249 pub fn new(terminal_id: impl Into<TerminalId>) -> Self {
250 Self {
251 terminal_id: terminal_id.into(),
252 command: MaybeUndefined::Undefined,
253 cwd: MaybeUndefined::Undefined,
254 output: MaybeUndefined::Undefined,
255 exit_status: MaybeUndefined::Undefined,
256 size: MaybeUndefined::Undefined,
257 meta: MaybeUndefined::Undefined,
258 }
259 }
260
261 #[must_use]
263 pub fn command(mut self, command: impl IntoMaybeUndefined<String>) -> Self {
264 self.command = command.into_maybe_undefined();
265 self
266 }
267
268 #[must_use]
270 pub fn cwd(mut self, cwd: impl IntoMaybeUndefined<AbsolutePath>) -> Self {
271 self.cwd = cwd.into_maybe_undefined();
272 self
273 }
274
275 #[must_use]
277 pub fn output(mut self, output: impl IntoMaybeUndefined<TerminalOutput>) -> Self {
278 self.output = output.into_maybe_undefined();
279 self
280 }
281
282 #[must_use]
284 pub fn exit_status(mut self, exit_status: impl IntoMaybeUndefined<TerminalExitStatus>) -> Self {
285 self.exit_status = exit_status.into_maybe_undefined();
286 self
287 }
288
289 #[must_use]
291 pub fn size(mut self, size: impl IntoMaybeUndefined<TerminalSize>) -> Self {
292 self.size = size.into_maybe_undefined();
293 self
294 }
295
296 #[must_use]
301 pub fn meta(mut self, meta: impl IntoMaybeUndefined<Meta>) -> Self {
302 self.meta = meta.into_maybe_undefined();
303 self
304 }
305
306 pub fn apply_update(&mut self, update: TerminalUpdate) {
313 debug_assert_eq!(self.terminal_id, update.terminal_id);
314 if !update.command.is_undefined() {
315 self.command = update.command;
316 }
317 if !update.cwd.is_undefined() {
318 self.cwd = update.cwd;
319 }
320 if !update.output.is_undefined() {
321 self.output = update.output;
322 }
323 if !update.exit_status.is_undefined() {
324 self.exit_status = update.exit_status;
325 }
326 if !update.size.is_undefined() {
327 self.size = update.size;
328 }
329 apply_meta_update(&mut self.meta, update.meta);
330 }
331}
332
333#[serde_as]
335#[skip_serializing_none]
336#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
337#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
338#[serde(rename_all = "camelCase")]
339#[non_exhaustive]
340pub struct TerminalSize {
341 #[cfg_attr(feature = "schemars", schemars(range(min = 1)))]
343 pub cols: u16,
344 #[cfg_attr(feature = "schemars", schemars(range(min = 1)))]
346 pub rows: u16,
347 #[serde_as(deserialize_as = "DefaultOnError")]
353 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
354 #[serde(default)]
355 #[serde(rename = "_meta")]
356 pub meta: Option<Meta>,
357}
358
359impl TerminalSize {
360 #[must_use]
362 pub fn new(cols: u16, rows: u16) -> Self {
363 Self {
364 cols,
365 rows,
366 meta: None,
367 }
368 }
369
370 #[must_use]
376 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
377 self.meta = meta.into_option();
378 self
379 }
380}
381
382#[serde_as]
384#[skip_serializing_none]
385#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
386#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
387#[serde(rename_all = "camelCase")]
388#[non_exhaustive]
389pub struct TerminalOutputChunk {
390 pub terminal_id: TerminalId,
392 #[cfg_attr(feature = "schemars", schemars(extend("contentEncoding" = "base64")))]
394 pub data: String,
395 #[serde_as(deserialize_as = "DefaultOnError")]
402 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
403 #[serde(default)]
404 #[serde(rename = "_meta")]
405 pub meta: Option<Meta>,
406}
407
408impl TerminalOutputChunk {
409 #[must_use]
411 pub fn new(terminal_id: impl Into<TerminalId>, data: impl Into<String>) -> Self {
412 Self {
413 terminal_id: terminal_id.into(),
414 data: data.into(),
415 meta: None,
416 }
417 }
418
419 #[must_use]
421 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
422 self.meta = meta.into_option();
423 self
424 }
425}
426
427#[cfg(test)]
428mod tests {
429
430 #[test]
431 fn terminal_size_is_an_optional_patch_field() {
432 let update = TerminalUpdate::new("term_1").size(TerminalSize::new(120, 40));
433 assert_eq!(
434 serde_json::to_value(&update).unwrap(),
435 serde_json::json!({ "terminalId": "term_1", "size": { "cols": 120, "rows": 40 } })
436 );
437
438 let mut stored = update;
439 stored.apply_update(TerminalUpdate::new("term_1").command("ls"));
440 assert_eq!(
441 stored.size,
442 MaybeUndefined::Value(TerminalSize::new(120, 40))
443 );
444 stored.apply_update(TerminalUpdate::new("term_1").size(None));
445 assert_eq!(stored.size, MaybeUndefined::Null);
446 }
447
448 #[test]
449 fn exit_code_accepts_negative_values() {
450 let status: TerminalExitStatus =
451 serde_json::from_value(serde_json::json!({ "exitCode": -1_073_741_819_i64 })).unwrap();
452 assert_eq!(status.exit_code, Some(-1_073_741_819));
453 }
454
455 use super::*;
456 use serde_json::{from_value, json, to_value};
457
458 #[test]
459 fn terminal_reference_meta_is_optional_and_content_scoped() {
460 let omitted = Terminal::new("term_1");
461 assert_eq!(
462 serde_json::to_value(&omitted).unwrap(),
463 serde_json::json!({
464 "terminalId": "term_1"
465 })
466 );
467
468 let null: Terminal = serde_json::from_value(serde_json::json!({
469 "terminalId": "term_1",
470 "_meta": null
471 }))
472 .unwrap();
473 assert_eq!(null.meta, None);
474
475 let mut meta = Meta::new();
476 meta.insert("view".to_string(), serde_json::json!("expanded"));
477 assert_eq!(
478 serde_json::to_value(Terminal::new("term_1").meta(meta)).unwrap(),
479 serde_json::json!({
480 "terminalId": "term_1",
481 "_meta": {
482 "view": "expanded"
483 }
484 })
485 );
486 }
487
488 #[test]
489 fn terminal_update_distinguishes_omitted_null_and_value() {
490 let update = TerminalUpdate::new("term_1")
491 .command("cargo test")
492 .cwd("/workspace/project")
493 .output(None::<TerminalOutput>)
494 .exit_status(TerminalExitStatus::new().exit_code(0));
495
496 assert_eq!(
497 serde_json::to_value(update).unwrap(),
498 serde_json::json!({
499 "terminalId": "term_1",
500 "command": "cargo test",
501 "cwd": "/workspace/project",
502 "output": null,
503 "exitStatus": {
504 "exitCode": 0
505 }
506 })
507 );
508
509 let parsed: TerminalUpdate = serde_json::from_value(serde_json::json!({
510 "terminalId": "term_1",
511 "command": null,
512 "cwd": "/workspace/project"
513 }))
514 .unwrap();
515 assert_eq!(parsed.command, MaybeUndefined::Null);
516 assert_eq!(
517 parsed.cwd,
518 MaybeUndefined::Value(AbsolutePath::new("/workspace/project"))
519 );
520 assert_eq!(parsed.output, MaybeUndefined::Undefined);
521 assert_eq!(parsed.exit_status, MaybeUndefined::Undefined);
522 assert_eq!(parsed.meta, MaybeUndefined::Undefined);
523 }
524
525 #[test]
526 fn terminal_update_applies_patch_fields() {
527 let mut stored = TerminalUpdate::new("term_1")
528 .command("cargo test")
529 .cwd("/workspace/project")
530 .output(TerminalOutput::new("b2xk"));
531
532 stored.apply_update(
533 TerminalUpdate::new("term_1")
534 .command(None::<String>)
535 .output(TerminalOutput::new("bmV3"))
536 .exit_status(TerminalExitStatus::new().signal("SIGTERM")),
537 );
538
539 assert_eq!(stored.command, MaybeUndefined::Null);
540 assert_eq!(
541 stored.cwd,
542 MaybeUndefined::Value(AbsolutePath::new("/workspace/project"))
543 );
544 assert_eq!(
545 stored.output,
546 MaybeUndefined::Value(TerminalOutput::new("bmV3"))
547 );
548 assert_eq!(
549 stored.exit_status,
550 MaybeUndefined::Value(TerminalExitStatus::new().signal("SIGTERM"))
551 );
552 }
553
554 #[test]
555 fn terminal_wire_patches_preserve_omitted_fields_and_replace_values() {
556 let initial = json!({
557 "terminalId": "term_1",
558 "_meta": {"source": "replay", "opaque": {"sequence": 1}}
559 });
560 let mut stored: TerminalUpdate = from_value(initial.clone()).unwrap();
561 assert_eq!(to_value(&stored).unwrap(), initial);
563
564 let populated = json!({
565 "terminalId": "term_1",
566 "command": "cargo check",
567 "cwd": "/workspace/project",
568 "output": {"data": "b2xk", "_meta": {"source": "snapshot"}},
569 "exitStatus": {"exitCode": 0, "_meta": {"observed": true}}
570 });
571 stored.apply_update(from_value(populated.clone()).unwrap());
572 let mut expected = populated;
573 expected["_meta"] = initial["_meta"].clone();
574 assert_eq!(to_value(&stored).unwrap(), expected);
575
576 for (field, empty) in [
577 ("command", json!("")),
578 ("output", json!({"data": ""})),
579 ("exitStatus", json!({})),
580 ] {
581 let original = expected[field].clone();
582 for replacement in [empty, json!(null), original] {
586 stored.apply_update(
587 from_value(json!({"terminalId": "term_1", (field): replacement})).unwrap(),
588 );
589 expected[field] = replacement;
590 assert_eq!(to_value(&stored).unwrap(), expected, "patching {field}");
591
592 stored.apply_update(from_value(json!({"terminalId": "term_1"})).unwrap());
593 assert_eq!(to_value(&stored).unwrap(), expected, "omitting {field}");
594 }
595 }
596
597 stored.apply_update(
599 from_value(json!({
600 "terminalId": "term_1",
601 "_meta": {"source": null, "opaque": {"other": 2}, "extra": true}
602 }))
603 .unwrap(),
604 );
605 expected["_meta"] = json!({"opaque": {"other": 2}, "extra": true});
606 assert_eq!(to_value(&stored).unwrap(), expected, "patching _meta keys");
607
608 stored.apply_update(from_value(json!({"terminalId": "term_1", "_meta": null})).unwrap());
609 expected["_meta"] = json!(null);
610 assert_eq!(to_value(&stored).unwrap(), expected, "clearing _meta");
611 }
612
613 #[test]
614 fn terminal_output_chunk_serializes_base64_data_and_meta() {
615 let mut meta = Meta::new();
616 meta.insert("source".to_string(), serde_json::json!("pty"));
617
618 assert_eq!(
619 serde_json::to_value(TerminalOutputChunk::new("term_1", "8J+agA==").meta(meta))
620 .unwrap(),
621 serde_json::json!({
622 "terminalId": "term_1",
623 "data": "8J+agA==",
624 "_meta": {
625 "source": "pty"
626 }
627 })
628 );
629 }
630
631 #[test]
632 fn terminal_output_meta_is_optional_and_snapshot_scoped() {
633 let omitted = TerminalOutput::new("b2s=");
634 assert_eq!(
635 serde_json::to_value(&omitted).unwrap(),
636 serde_json::json!({ "data": "b2s=" })
637 );
638
639 let null: TerminalOutput = serde_json::from_value(serde_json::json!({
640 "data": "b2s=",
641 "_meta": null
642 }))
643 .unwrap();
644 assert_eq!(null, omitted);
645
646 let mut meta = Meta::new();
647 meta.insert("source".to_string(), serde_json::json!("replay"));
648 assert_eq!(
649 serde_json::to_value(TerminalOutput::new("b2s=").meta(meta)).unwrap(),
650 serde_json::json!({
651 "data": "b2s=",
652 "_meta": {
653 "source": "replay"
654 }
655 })
656 );
657 }
658
659 #[cfg(feature = "schemars")]
660 #[test]
661 fn terminal_output_fields_use_base64_content_encoding() {
662 let output = serde_json::to_value(schemars::schema_for!(TerminalOutput)).unwrap();
663 assert_eq!(output["properties"]["data"]["contentEncoding"], "base64");
664 assert!(output["properties"]["data"].get("format").is_none());
665 assert_eq!(output["required"], serde_json::json!(["data"]));
666 assert!(output["properties"].get("_meta").is_some());
667 assert!(output["properties"].get("truncated").is_none());
668
669 let chunk = serde_json::to_value(schemars::schema_for!(TerminalOutputChunk)).unwrap();
670 assert_eq!(chunk["properties"]["data"]["contentEncoding"], "base64");
671 assert!(chunk["properties"]["data"].get("format").is_none());
672 }
673
674 #[test]
675 fn terminal_exit_status_treats_omitted_and_null_as_unknown() {
676 let omitted: TerminalExitStatus = serde_json::from_value(serde_json::json!({})).unwrap();
677 let null: TerminalExitStatus = serde_json::from_value(serde_json::json!({
678 "exitCode": null,
679 "signal": null,
680 "_meta": null
681 }))
682 .unwrap();
683
684 assert_eq!(omitted, TerminalExitStatus::new());
685 assert_eq!(null, TerminalExitStatus::new());
686 assert_eq!(serde_json::to_value(null).unwrap(), serde_json::json!({}));
687 }
688
689 #[test]
690 fn terminal_exit_status_meta_is_exit_scoped() {
691 let mut meta = Meta::new();
692 meta.insert("reason".to_string(), serde_json::json!("timeout"));
693
694 assert_eq!(
695 serde_json::to_value(TerminalExitStatus::new().signal("SIGTERM").meta(meta)).unwrap(),
696 serde_json::json!({
697 "signal": "SIGTERM",
698 "_meta": {
699 "reason": "timeout"
700 }
701 })
702 );
703 }
704}