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};
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, 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]
194#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
195#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase")]
197#[non_exhaustive]
198pub struct TerminalUpdate {
199 pub terminal_id: TerminalId,
201 #[serde_as(deserialize_as = "DefaultOnError")]
203 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
204 #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
205 pub command: MaybeUndefined<String>,
206 #[serde_as(deserialize_as = "DefaultOnError")]
208 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
209 #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
210 pub cwd: MaybeUndefined<AbsolutePath>,
211 #[serde_as(deserialize_as = "DefaultOnError")]
213 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
214 #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
215 pub output: MaybeUndefined<TerminalOutput>,
216 #[serde_as(deserialize_as = "DefaultOnError")]
218 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
219 #[serde(default, skip_serializing_if = "MaybeUndefined::is_undefined")]
220 pub exit_status: MaybeUndefined<TerminalExitStatus>,
221 #[serde_as(deserialize_as = "DefaultOnError<MaybeUndefined<_>>")]
227 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
228 #[serde(
229 rename = "_meta",
230 default,
231 skip_serializing_if = "MaybeUndefined::is_undefined"
232 )]
233 pub meta: MaybeUndefined<Meta>,
234}
235
236impl TerminalUpdate {
237 #[must_use]
239 pub fn new(terminal_id: impl Into<TerminalId>) -> Self {
240 Self {
241 terminal_id: terminal_id.into(),
242 command: MaybeUndefined::Undefined,
243 cwd: MaybeUndefined::Undefined,
244 output: MaybeUndefined::Undefined,
245 exit_status: MaybeUndefined::Undefined,
246 meta: MaybeUndefined::Undefined,
247 }
248 }
249
250 #[must_use]
252 pub fn command(mut self, command: impl IntoMaybeUndefined<String>) -> Self {
253 self.command = command.into_maybe_undefined();
254 self
255 }
256
257 #[must_use]
259 pub fn cwd(mut self, cwd: impl IntoMaybeUndefined<AbsolutePath>) -> Self {
260 self.cwd = cwd.into_maybe_undefined();
261 self
262 }
263
264 #[must_use]
266 pub fn output(mut self, output: impl IntoMaybeUndefined<TerminalOutput>) -> Self {
267 self.output = output.into_maybe_undefined();
268 self
269 }
270
271 #[must_use]
273 pub fn exit_status(mut self, exit_status: impl IntoMaybeUndefined<TerminalExitStatus>) -> Self {
274 self.exit_status = exit_status.into_maybe_undefined();
275 self
276 }
277
278 #[must_use]
280 pub fn meta(mut self, meta: impl IntoMaybeUndefined<Meta>) -> Self {
281 self.meta = meta.into_maybe_undefined();
282 self
283 }
284
285 pub fn apply_update(&mut self, update: TerminalUpdate) {
290 debug_assert_eq!(self.terminal_id, update.terminal_id);
291 if !update.command.is_undefined() {
292 self.command = update.command;
293 }
294 if !update.cwd.is_undefined() {
295 self.cwd = update.cwd;
296 }
297 if !update.output.is_undefined() {
298 self.output = update.output;
299 }
300 if !update.exit_status.is_undefined() {
301 self.exit_status = update.exit_status;
302 }
303 if !update.meta.is_undefined() {
304 self.meta = update.meta;
305 }
306 }
307}
308
309#[serde_as]
311#[skip_serializing_none]
312#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
313#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
314#[serde(rename_all = "camelCase")]
315#[non_exhaustive]
316pub struct TerminalOutputChunk {
317 pub terminal_id: TerminalId,
319 #[cfg_attr(feature = "schemars", schemars(extend("contentEncoding" = "base64")))]
321 pub data: String,
322 #[serde_as(deserialize_as = "DefaultOnError")]
329 #[cfg_attr(feature = "schemars", schemars(extend("x-deserialize-default-on-error" = true)))]
330 #[serde(default)]
331 #[serde(rename = "_meta")]
332 pub meta: Option<Meta>,
333}
334
335impl TerminalOutputChunk {
336 #[must_use]
338 pub fn new(terminal_id: impl Into<TerminalId>, data: impl Into<String>) -> Self {
339 Self {
340 terminal_id: terminal_id.into(),
341 data: data.into(),
342 meta: None,
343 }
344 }
345
346 #[must_use]
348 pub fn meta(mut self, meta: impl IntoOption<Meta>) -> Self {
349 self.meta = meta.into_option();
350 self
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use serde_json::{from_value, json, to_value};
358
359 #[test]
360 fn terminal_reference_meta_is_optional_and_content_scoped() {
361 let omitted = Terminal::new("term_1");
362 assert_eq!(
363 serde_json::to_value(&omitted).unwrap(),
364 serde_json::json!({
365 "terminalId": "term_1"
366 })
367 );
368
369 let null: Terminal = serde_json::from_value(serde_json::json!({
370 "terminalId": "term_1",
371 "_meta": null
372 }))
373 .unwrap();
374 assert_eq!(null.meta, None);
375
376 let mut meta = Meta::new();
377 meta.insert("view".to_string(), serde_json::json!("expanded"));
378 assert_eq!(
379 serde_json::to_value(Terminal::new("term_1").meta(meta)).unwrap(),
380 serde_json::json!({
381 "terminalId": "term_1",
382 "_meta": {
383 "view": "expanded"
384 }
385 })
386 );
387 }
388
389 #[test]
390 fn terminal_update_distinguishes_omitted_null_and_value() {
391 let update = TerminalUpdate::new("term_1")
392 .command("cargo test")
393 .cwd("/workspace/project")
394 .output(None::<TerminalOutput>)
395 .exit_status(TerminalExitStatus::new().exit_code(0));
396
397 assert_eq!(
398 serde_json::to_value(update).unwrap(),
399 serde_json::json!({
400 "terminalId": "term_1",
401 "command": "cargo test",
402 "cwd": "/workspace/project",
403 "output": null,
404 "exitStatus": {
405 "exitCode": 0
406 }
407 })
408 );
409
410 let parsed: TerminalUpdate = serde_json::from_value(serde_json::json!({
411 "terminalId": "term_1",
412 "command": null,
413 "cwd": "/workspace/project"
414 }))
415 .unwrap();
416 assert_eq!(parsed.command, MaybeUndefined::Null);
417 assert_eq!(
418 parsed.cwd,
419 MaybeUndefined::Value(AbsolutePath::new("/workspace/project"))
420 );
421 assert_eq!(parsed.output, MaybeUndefined::Undefined);
422 assert_eq!(parsed.exit_status, MaybeUndefined::Undefined);
423 assert_eq!(parsed.meta, MaybeUndefined::Undefined);
424 }
425
426 #[test]
427 fn terminal_update_applies_patch_fields() {
428 let mut stored = TerminalUpdate::new("term_1")
429 .command("cargo test")
430 .cwd("/workspace/project")
431 .output(TerminalOutput::new("b2xk"));
432
433 stored.apply_update(
434 TerminalUpdate::new("term_1")
435 .command(None::<String>)
436 .output(TerminalOutput::new("bmV3"))
437 .exit_status(TerminalExitStatus::new().signal("SIGTERM")),
438 );
439
440 assert_eq!(stored.command, MaybeUndefined::Null);
441 assert_eq!(
442 stored.cwd,
443 MaybeUndefined::Value(AbsolutePath::new("/workspace/project"))
444 );
445 assert_eq!(
446 stored.output,
447 MaybeUndefined::Value(TerminalOutput::new("bmV3"))
448 );
449 assert_eq!(
450 stored.exit_status,
451 MaybeUndefined::Value(TerminalExitStatus::new().signal("SIGTERM"))
452 );
453 }
454
455 #[test]
456 fn terminal_wire_patches_preserve_omitted_fields_and_replace_values() {
457 let initial = json!({
458 "terminalId": "term_1",
459 "_meta": {"source": "replay", "opaque": {"sequence": 1}}
460 });
461 let mut stored: TerminalUpdate = from_value(initial.clone()).unwrap();
462 assert_eq!(to_value(&stored).unwrap(), initial);
464
465 let populated = json!({
466 "terminalId": "term_1",
467 "command": "cargo check",
468 "cwd": "/workspace/project",
469 "output": {"data": "b2xk", "_meta": {"source": "snapshot"}},
470 "exitStatus": {"exitCode": 0, "_meta": {"observed": true}}
471 });
472 stored.apply_update(from_value(populated.clone()).unwrap());
473 let mut expected = populated;
474 expected["_meta"] = initial["_meta"].clone();
475 assert_eq!(to_value(&stored).unwrap(), expected);
476
477 for (field, empty) in [
478 ("command", json!("")),
479 ("output", json!({"data": ""})),
480 ("exitStatus", json!({})),
481 ("_meta", json!({})),
482 ] {
483 let original = expected[field].clone();
484 for replacement in [empty, json!(null), original] {
488 stored.apply_update(
489 from_value(json!({"terminalId": "term_1", (field): replacement})).unwrap(),
490 );
491 expected[field] = replacement;
492 assert_eq!(to_value(&stored).unwrap(), expected, "patching {field}");
493
494 stored.apply_update(from_value(json!({"terminalId": "term_1"})).unwrap());
495 assert_eq!(to_value(&stored).unwrap(), expected, "omitting {field}");
496 }
497 }
498 }
499
500 #[test]
501 fn terminal_output_chunk_serializes_base64_data_and_meta() {
502 let mut meta = Meta::new();
503 meta.insert("source".to_string(), serde_json::json!("pty"));
504
505 assert_eq!(
506 serde_json::to_value(TerminalOutputChunk::new("term_1", "8J+agA==").meta(meta))
507 .unwrap(),
508 serde_json::json!({
509 "terminalId": "term_1",
510 "data": "8J+agA==",
511 "_meta": {
512 "source": "pty"
513 }
514 })
515 );
516 }
517
518 #[test]
519 fn terminal_output_meta_is_optional_and_snapshot_scoped() {
520 let omitted = TerminalOutput::new("b2s=");
521 assert_eq!(
522 serde_json::to_value(&omitted).unwrap(),
523 serde_json::json!({ "data": "b2s=" })
524 );
525
526 let null: TerminalOutput = serde_json::from_value(serde_json::json!({
527 "data": "b2s=",
528 "_meta": null
529 }))
530 .unwrap();
531 assert_eq!(null, omitted);
532
533 let mut meta = Meta::new();
534 meta.insert("source".to_string(), serde_json::json!("replay"));
535 assert_eq!(
536 serde_json::to_value(TerminalOutput::new("b2s=").meta(meta)).unwrap(),
537 serde_json::json!({
538 "data": "b2s=",
539 "_meta": {
540 "source": "replay"
541 }
542 })
543 );
544 }
545
546 #[cfg(feature = "schemars")]
547 #[test]
548 fn terminal_output_fields_use_base64_content_encoding() {
549 let output = serde_json::to_value(schemars::schema_for!(TerminalOutput)).unwrap();
550 assert_eq!(output["properties"]["data"]["contentEncoding"], "base64");
551 assert!(output["properties"]["data"].get("format").is_none());
552 assert_eq!(output["required"], serde_json::json!(["data"]));
553 assert!(output["properties"].get("_meta").is_some());
554 assert!(output["properties"].get("truncated").is_none());
555
556 let chunk = serde_json::to_value(schemars::schema_for!(TerminalOutputChunk)).unwrap();
557 assert_eq!(chunk["properties"]["data"]["contentEncoding"], "base64");
558 assert!(chunk["properties"]["data"].get("format").is_none());
559 }
560
561 #[test]
562 fn terminal_exit_status_treats_omitted_and_null_as_unknown() {
563 let omitted: TerminalExitStatus = serde_json::from_value(serde_json::json!({})).unwrap();
564 let null: TerminalExitStatus = serde_json::from_value(serde_json::json!({
565 "exitCode": null,
566 "signal": null,
567 "_meta": null
568 }))
569 .unwrap();
570
571 assert_eq!(omitted, TerminalExitStatus::new());
572 assert_eq!(null, TerminalExitStatus::new());
573 assert_eq!(serde_json::to_value(null).unwrap(), serde_json::json!({}));
574 }
575
576 #[test]
577 fn terminal_exit_status_meta_is_exit_scoped() {
578 let mut meta = Meta::new();
579 meta.insert("reason".to_string(), serde_json::json!("timeout"));
580
581 assert_eq!(
582 serde_json::to_value(TerminalExitStatus::new().signal("SIGTERM").meta(meta)).unwrap(),
583 serde_json::json!({
584 "signal": "SIGTERM",
585 "_meta": {
586 "reason": "timeout"
587 }
588 })
589 );
590 }
591}