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")]
54 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
55 #[serde(default)]
56 #[serde(rename = "_meta")]
57 pub meta: Option<Meta>,
58}
59
60impl Terminal {
61 #[must_use]
63 pub fn new(terminal_id: impl Into<TerminalId>) -> Self {
64 Self {
65 terminal_id: terminal_id.into(),
66 meta: None,
67 }
68 }
69
70 #[must_use]
72 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
73 self.meta = meta.into_option();
74 self
75 }
76}
77
78#[serde_as]
80#[skip_serializing_none]
81#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "camelCase")]
84#[non_exhaustive]
85pub struct TerminalOutput {
86 #[cfg_attr(feature = "schemars", schemars(extend("contentEncoding" = "base64")))]
88 pub data: String,
89 #[serde_as(deserialize_as = "DefaultOnError")]
96 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
97 #[serde(default)]
98 #[serde(rename = "_meta")]
99 pub meta: Option<Meta>,
100}
101
102impl TerminalOutput {
103 #[must_use]
105 pub fn new(data: impl Into<String>) -> Self {
106 Self {
107 data: data.into(),
108 meta: None,
109 }
110 }
111
112 #[must_use]
114 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
115 self.meta = meta.into_option();
116 self
117 }
118}
119
120#[serde_as]
125#[skip_serializing_none]
126#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
127#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "camelCase")]
129#[non_exhaustive]
130pub struct TerminalExitStatus {
131 #[serde_as(deserialize_as = "DefaultOnError")]
133 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
134 #[serde(default)]
135 pub exit_code: Option<u32>,
136 #[serde_as(deserialize_as = "DefaultOnError")]
142 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
143 #[serde(default)]
144 pub signal: Option<String>,
145 #[serde_as(deserialize_as = "DefaultOnError")]
152 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
153 #[serde(default)]
154 #[serde(rename = "_meta")]
155 pub meta: Option<Meta>,
156}
157
158impl TerminalExitStatus {
159 #[must_use]
161 pub fn new() -> Self {
162 Self::default()
163 }
164
165 #[must_use]
167 pub fn exit_code(mut self, exit_code: impl IntoOption<u32>) -> Self {
168 self.exit_code = exit_code.into_option();
169 self
170 }
171
172 #[must_use]
174 pub fn signal(mut self, signal: impl IntoOption<String>) -> Self {
175 self.signal = signal.into_option();
176 self
177 }
178
179 #[must_use]
181 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
182 self.meta = meta.into_option();
183 self
184 }
185}
186
187#[serde_as]
195#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(rename_all = "camelCase")]
198#[non_exhaustive]
199pub struct TerminalUpdate {
200 pub terminal_id: TerminalId,
202 #[serde_as(deserialize_as = "DefaultOnError")]
204 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
205 #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
206 pub command: MaybeUndefined<String>,
207 #[serde_as(deserialize_as = "DefaultOnError")]
209 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
210 #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
211 pub cwd: MaybeUndefined<AbsolutePath>,
212 #[serde_as(deserialize_as = "DefaultOnError")]
214 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
215 #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
216 pub output: MaybeUndefined<TerminalOutput>,
217 #[serde_as(deserialize_as = "DefaultOnError")]
219 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
220 #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
221 pub exit_status: MaybeUndefined<TerminalExitStatus>,
222 #[serde_as(deserialize_as = "DefaultOnError<MaybeUndefined<_>>")]
231 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
232 #[serde(
233 rename = "_meta",
234 default,
235 skip_serializing_if = "MaybeUndefined::is_undefined"
236 )]
237 pub meta: MaybeUndefined<Meta>,
238}
239
240impl TerminalUpdate {
241 #[must_use]
243 pub fn new(terminal_id: impl Into<TerminalId>) -> Self {
244 Self {
245 terminal_id: terminal_id.into(),
246 command: MaybeUndefined::Undefined,
247 cwd: MaybeUndefined::Undefined,
248 output: MaybeUndefined::Undefined,
249 exit_status: MaybeUndefined::Undefined,
250 meta: MaybeUndefined::Undefined,
251 }
252 }
253
254 #[must_use]
256 pub fn command(mut self, command: impl IntoMaybeUndefined<String>) -> Self {
257 self.command = command.into_maybe_undefined();
258 self
259 }
260
261 #[must_use]
263 pub fn cwd(mut self, cwd: impl IntoMaybeUndefined<AbsolutePath>) -> Self {
264 self.cwd = cwd.into_maybe_undefined();
265 self
266 }
267
268 #[must_use]
270 pub fn output(mut self, output: impl IntoMaybeUndefined<TerminalOutput>) -> Self {
271 self.output = output.into_maybe_undefined();
272 self
273 }
274
275 #[must_use]
277 pub fn exit_status(mut self, exit_status: impl IntoMaybeUndefined<TerminalExitStatus>) -> Self {
278 self.exit_status = exit_status.into_maybe_undefined();
279 self
280 }
281
282 #[must_use]
287 pub fn meta(mut self, meta: impl IntoMaybeUndefined<Meta>) -> Self {
288 self.meta = meta.into_maybe_undefined();
289 self
290 }
291
292 pub fn apply_update(&mut self, update: TerminalUpdate) {
299 debug_assert_eq!(self.terminal_id, update.terminal_id);
300 if !update.command.is_undefined() {
301 self.command = update.command;
302 }
303 if !update.cwd.is_undefined() {
304 self.cwd = update.cwd;
305 }
306 if !update.output.is_undefined() {
307 self.output = update.output;
308 }
309 if !update.exit_status.is_undefined() {
310 self.exit_status = update.exit_status;
311 }
312 apply_meta_update(&mut self.meta, update.meta);
313 }
314}
315
316#[serde_as]
318#[skip_serializing_none]
319#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
320#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
321#[serde(rename_all = "camelCase")]
322#[non_exhaustive]
323pub struct TerminalOutputChunk {
324 pub terminal_id: TerminalId,
326 #[cfg_attr(feature = "schemars", schemars(extend("contentEncoding" = "base64")))]
328 pub data: String,
329 #[serde_as(deserialize_as = "DefaultOnError")]
336 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
337 #[serde(default)]
338 #[serde(rename = "_meta")]
339 pub meta: Option<Meta>,
340}
341
342impl TerminalOutputChunk {
343 #[must_use]
345 pub fn new(terminal_id: impl Into<TerminalId>, data: impl Into<String>) -> Self {
346 Self {
347 terminal_id: terminal_id.into(),
348 data: data.into(),
349 meta: None,
350 }
351 }
352
353 #[must_use]
355 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
356 self.meta = meta.into_option();
357 self
358 }
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use serde_json::{from_value, json, to_value};
365
366 #[test]
367 fn terminal_reference_meta_is_optional_and_content_scoped() {
368 let omitted = Terminal::new("term_1");
369 assert_eq!(
370 serde_json::to_value(&omitted).unwrap(),
371 serde_json::json!({
372 "terminalId": "term_1"
373 })
374 );
375
376 let null: Terminal = serde_json::from_value(serde_json::json!({
377 "terminalId": "term_1",
378 "_meta": null
379 }))
380 .unwrap();
381 assert_eq!(null.meta, None);
382
383 let mut meta = Meta::new();
384 meta.insert("view".to_string(), serde_json::json!("expanded"));
385 assert_eq!(
386 serde_json::to_value(Terminal::new("term_1").meta(meta)).unwrap(),
387 serde_json::json!({
388 "terminalId": "term_1",
389 "_meta": {
390 "view": "expanded"
391 }
392 })
393 );
394 }
395
396 #[test]
397 fn terminal_update_distinguishes_omitted_null_and_value() {
398 let update = TerminalUpdate::new("term_1")
399 .command("cargo test")
400 .cwd("/workspace/project")
401 .output(None::<TerminalOutput>)
402 .exit_status(TerminalExitStatus::new().exit_code(0));
403
404 assert_eq!(
405 serde_json::to_value(update).unwrap(),
406 serde_json::json!({
407 "terminalId": "term_1",
408 "command": "cargo test",
409 "cwd": "/workspace/project",
410 "output": null,
411 "exitStatus": {
412 "exitCode": 0
413 }
414 })
415 );
416
417 let parsed: TerminalUpdate = serde_json::from_value(serde_json::json!({
418 "terminalId": "term_1",
419 "command": null,
420 "cwd": "/workspace/project"
421 }))
422 .unwrap();
423 assert_eq!(parsed.command, MaybeUndefined::Null);
424 assert_eq!(
425 parsed.cwd,
426 MaybeUndefined::Value(AbsolutePath::new("/workspace/project"))
427 );
428 assert_eq!(parsed.output, MaybeUndefined::Undefined);
429 assert_eq!(parsed.exit_status, MaybeUndefined::Undefined);
430 assert_eq!(parsed.meta, MaybeUndefined::Undefined);
431 }
432
433 #[test]
434 fn terminal_update_applies_patch_fields() {
435 let mut stored = TerminalUpdate::new("term_1")
436 .command("cargo test")
437 .cwd("/workspace/project")
438 .output(TerminalOutput::new("b2xk"));
439
440 stored.apply_update(
441 TerminalUpdate::new("term_1")
442 .command(None::<String>)
443 .output(TerminalOutput::new("bmV3"))
444 .exit_status(TerminalExitStatus::new().signal("SIGTERM")),
445 );
446
447 assert_eq!(stored.command, MaybeUndefined::Null);
448 assert_eq!(
449 stored.cwd,
450 MaybeUndefined::Value(AbsolutePath::new("/workspace/project"))
451 );
452 assert_eq!(
453 stored.output,
454 MaybeUndefined::Value(TerminalOutput::new("bmV3"))
455 );
456 assert_eq!(
457 stored.exit_status,
458 MaybeUndefined::Value(TerminalExitStatus::new().signal("SIGTERM"))
459 );
460 }
461
462 #[test]
463 fn terminal_wire_patches_preserve_omitted_fields_and_replace_values() {
464 let initial = json!({
465 "terminalId": "term_1",
466 "_meta": {"source": "replay", "opaque": {"sequence": 1}}
467 });
468 let mut stored: TerminalUpdate = from_value(initial.clone()).unwrap();
469 assert_eq!(to_value(&stored).unwrap(), initial);
471
472 let populated = json!({
473 "terminalId": "term_1",
474 "command": "cargo check",
475 "cwd": "/workspace/project",
476 "output": {"data": "b2xk", "_meta": {"source": "snapshot"}},
477 "exitStatus": {"exitCode": 0, "_meta": {"observed": true}}
478 });
479 stored.apply_update(from_value(populated.clone()).unwrap());
480 let mut expected = populated;
481 expected["_meta"] = initial["_meta"].clone();
482 assert_eq!(to_value(&stored).unwrap(), expected);
483
484 for (field, empty) in [
485 ("command", json!("")),
486 ("output", json!({"data": ""})),
487 ("exitStatus", json!({})),
488 ] {
489 let original = expected[field].clone();
490 for replacement in [empty, json!(null), original] {
494 stored.apply_update(
495 from_value(json!({"terminalId": "term_1", (field): replacement})).unwrap(),
496 );
497 expected[field] = replacement;
498 assert_eq!(to_value(&stored).unwrap(), expected, "patching {field}");
499
500 stored.apply_update(from_value(json!({"terminalId": "term_1"})).unwrap());
501 assert_eq!(to_value(&stored).unwrap(), expected, "omitting {field}");
502 }
503 }
504
505 stored.apply_update(
507 from_value(json!({
508 "terminalId": "term_1",
509 "_meta": {"source": null, "opaque": {"other": 2}, "extra": true}
510 }))
511 .unwrap(),
512 );
513 expected["_meta"] = json!({"opaque": {"other": 2}, "extra": true});
514 assert_eq!(to_value(&stored).unwrap(), expected, "patching _meta keys");
515
516 stored.apply_update(from_value(json!({"terminalId": "term_1", "_meta": null})).unwrap());
517 expected["_meta"] = json!(null);
518 assert_eq!(to_value(&stored).unwrap(), expected, "clearing _meta");
519 }
520
521 #[test]
522 fn terminal_output_chunk_serializes_base64_data_and_meta() {
523 let mut meta = Meta::new();
524 meta.insert("source".to_string(), serde_json::json!("pty"));
525
526 assert_eq!(
527 serde_json::to_value(TerminalOutputChunk::new("term_1", "8J+agA==").meta(meta))
528 .unwrap(),
529 serde_json::json!({
530 "terminalId": "term_1",
531 "data": "8J+agA==",
532 "_meta": {
533 "source": "pty"
534 }
535 })
536 );
537 }
538
539 #[test]
540 fn terminal_output_meta_is_optional_and_snapshot_scoped() {
541 let omitted = TerminalOutput::new("b2s=");
542 assert_eq!(
543 serde_json::to_value(&omitted).unwrap(),
544 serde_json::json!({ "data": "b2s=" })
545 );
546
547 let null: TerminalOutput = serde_json::from_value(serde_json::json!({
548 "data": "b2s=",
549 "_meta": null
550 }))
551 .unwrap();
552 assert_eq!(null, omitted);
553
554 let mut meta = Meta::new();
555 meta.insert("source".to_string(), serde_json::json!("replay"));
556 assert_eq!(
557 serde_json::to_value(TerminalOutput::new("b2s=").meta(meta)).unwrap(),
558 serde_json::json!({
559 "data": "b2s=",
560 "_meta": {
561 "source": "replay"
562 }
563 })
564 );
565 }
566
567 #[cfg(feature = "schemars")]
568 #[test]
569 fn terminal_output_fields_use_base64_content_encoding() {
570 let output = serde_json::to_value(schemars::schema_for!(TerminalOutput)).unwrap();
571 assert_eq!(output["properties"]["data"]["contentEncoding"], "base64");
572 assert!(output["properties"]["data"].get("format").is_none());
573 assert_eq!(output["required"], serde_json::json!(["data"]));
574 assert!(output["properties"].get("_meta").is_some());
575 assert!(output["properties"].get("truncated").is_none());
576
577 let chunk = serde_json::to_value(schemars::schema_for!(TerminalOutputChunk)).unwrap();
578 assert_eq!(chunk["properties"]["data"]["contentEncoding"], "base64");
579 assert!(chunk["properties"]["data"].get("format").is_none());
580 }
581
582 #[test]
583 fn terminal_exit_status_treats_omitted_and_null_as_unknown() {
584 let omitted: TerminalExitStatus = serde_json::from_value(serde_json::json!({})).unwrap();
585 let null: TerminalExitStatus = serde_json::from_value(serde_json::json!({
586 "exitCode": null,
587 "signal": null,
588 "_meta": null
589 }))
590 .unwrap();
591
592 assert_eq!(omitted, TerminalExitStatus::new());
593 assert_eq!(null, TerminalExitStatus::new());
594 assert_eq!(serde_json::to_value(null).unwrap(), serde_json::json!({}));
595 }
596
597 #[test]
598 fn terminal_exit_status_meta_is_exit_scoped() {
599 let mut meta = Meta::new();
600 meta.insert("reason".to_string(), serde_json::json!("timeout"));
601
602 assert_eq!(
603 serde_json::to_value(TerminalExitStatus::new().signal("SIGTERM").meta(meta)).unwrap(),
604 serde_json::json!({
605 "signal": "SIGTERM",
606 "_meta": {
607 "reason": "timeout"
608 }
609 })
610 );
611 }
612}