//! Wire types shared between the Maestro daemon and its clients (CLI, TUI, web). //! //! Maestro is single-host: one daemon owns every session on the machine, and //! clients talk to it directly over `/api/*`. There is no registry, no peer //! federation, and no authentication layer — reaching the daemon's bind address //! *is* the authorization, same trust model as tmux itself. #![deny(missing_debug_implementations)] #[cfg(feature = "client")] pub mod client; pub mod routes; #[cfg(feature = "client")] pub use client::{ClientError, DaemonClient}; use std::collections::HashMap; use std::path::PathBuf; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use serde_json::Value; use uuid::Uuid; /// The protocol version this crate implements. Advertised in `GET /api/info`. pub const PROTOCOL_VERSION: &str = env!("CARGO_PKG_VERSION"); /// True when a daemon advertising `daemon_version` speaks the same protocol as /// this build. /// /// Compared on the major component only, per semver: within a major, added /// fields are `#[serde(default)]` and so are forward-compatible. An empty or /// unparseable version is treated as compatible — daemons that predate the /// field should not trip a warning. pub fn is_compatible(daemon_version: &str) -> bool { fn major(v: &str) -> Option<&str> { let m = v.split('.').next()?; (!m.is_empty() && m.bytes().all(|b| b.is_ascii_digit())).then_some(m) } match (major(daemon_version), major(PROTOCOL_VERSION)) { (Some(theirs), Some(ours)) => theirs == ours, _ => true, } } /// A live or recently-dead tmux session managed by the daemon. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Session { pub id: Uuid, pub cwd: PathBuf, pub command: String, #[serde(default)] pub env: HashMap, #[serde(default)] pub metadata: HashMap, /// The worktree this session owns, when it was spawned with one. /// /// Persisted rather than kept in memory only: after a daemon restart the /// recovered session must still know what to clean up, and with /// deterministic worktree paths a forgotten association leaves a directory /// that blocks that branch from ever being spawned again (#54 review). #[serde(default, skip_serializing_if = "Option::is_none")] pub worktree: Option, pub status: SessionStatus, pub created_at: DateTime, /// Current foreground command in the session's pane (e.g. `claude`, /// `bash`), as tmux reports it — distinct from the `command` it was spawned /// with. Daemon-derived (NAP-340); `None` until the daemon's activity /// watcher has observed it. All three activity fields default on the wire so /// older daemons/payloads stay forward-compatible. #[serde(default)] pub foreground: Option, /// Inferred activity dimension, orthogonal to [`SessionStatus`] /// (NAP-340). Derived from structured tmux/`/proc` state, never from the /// byte stream. #[serde(default)] pub activity: ActivityState, /// Wall-clock time of the session's most recent observed PTY output. /// Drives "idle for Ns" displays. Defaults to the Unix epoch on payloads /// from daemons that predate the field. #[serde(default = "epoch")] pub last_activity_at: DateTime, /// The pane's *current* working directory (`#{pane_current_path}`) — distinct /// from [`Session::cwd`], the directory it was spawned in. The activity /// watcher refreshes it as the user `cd`s around; it drives the web UI's /// directory-based session tree. `None` until first observed, and on payloads /// from daemons that predate the field. #[serde(default)] pub current_cwd: Option, /// Whether the session's worktree currently holds anything that would /// block an unforced kill (#83), summarised by [`InTheWay`]'s `Display`. /// Refreshed by the daemon's watcher; `None` for sessions without a /// worktree and until the first probe. Defaults on the wire so payloads /// from daemons that predate the field stay forward-compatible. #[serde(default)] pub blocked_work: Option, } /// Default for [`Session::last_activity_at`] when an older payload omits it. fn epoch() -> DateTime { DateTime::from_timestamp(0, 0).expect("unix epoch is a valid timestamp") } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum SessionStatus { Running, Dead, } /// Inferred session activity, orthogonal to [`SessionStatus`] (NAP-340). The /// daemon derives this from tmux pane state, PTY quiescence, and `/proc` /// inspection — never by parsing the terminal byte stream. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] pub enum ActivityState { /// Producing output, or the foreground process is busy. Active, /// Foreground process alive but quiescent. The forward-compatible default. #[default] Idle, /// Blocked on a `read()` from the pty — waiting on the user. AwaitingInput, } /// Optional worktree request attached to a `CreateSessionRequest`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WorktreeSpec { pub base_repo: PathBuf, pub branch: String, /// When false, `branch` must already exist and is checked out as-is /// (#56); when true (the default, and what every payload meant before /// the field existed) the daemon creates it with `worktree add -b`. #[serde(default = "default_true")] pub create_branch: bool, } fn default_true() -> bool { true } /// The worktree a running session owns, carried on [`Session`] so it survives /// a daemon restart. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SessionWorktree { pub path: PathBuf, pub base_repo: PathBuf, } /// Why a retained worktree outlived its session (#80). Same values the /// daemon's retained store persists; new variants are additive but clients /// should render unknown reasons generically. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum RetainedReason { /// The session was killed with `--force` while its worktree held unsaved /// work. ForceKilled, /// The session ended by itself and its worktree held unsaved work. ExitedWithUnsavedWork, } /// What a kill refusal would report about a worktree right now (#80) — the /// same breakdown that blocks an unforced kill, recomputed on every read of /// [`RetainedWorktree`] so it reflects the tree now rather than when it was /// retained. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct InTheWay { /// Tracked files with uncommitted modifications. #[serde(default)] pub uncommitted_files: usize, /// Untracked files that are not ignored (ignored files never block). #[serde(default)] pub untracked_files: usize, /// Commits on the branch reachable from no remote-tracking ref. #[serde(default)] pub unpushed_commits: usize, } impl InTheWay { /// Nothing blocks any more — the work has since been committed and pushed. pub fn is_clear(&self) -> bool { *self == Self::default() } } /// One entry of `GET /api/worktrees` (#80): a retained worktree with its /// in-the-way state as of this read. Reclaiming it via /// `DELETE /api/worktrees/:id` removes the directory but **never** the branch. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RetainedWorktree { pub id: Uuid, pub path: PathBuf, pub base_repo: PathBuf, pub branch: String, pub reason: RetainedReason, pub retained_at: DateTime, #[serde(default)] pub in_the_way: InTheWay, } /// Body of a successful forced `DELETE /api/sessions/:id?force=true` (#81). /// A forced kill ends the session and retains its worktree instead of /// removing it; this names what was kept, where, and why, so a client can say /// where the work went rather than leaving the operator to wonder. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct KillOutcome { /// Set when the worktree was retained with reason `force-killed`; `None` /// when there was nothing to retain — no worktree at all, or one whose /// guard was clear, which a forced kill removes as usual. #[serde(default)] pub retained: Option, } impl std::fmt::Display for InTheWay { /// "3 uncommitted files, 2 unpushed commits" — zero parts are omitted; /// all-zero renders "no unsaved work". This is the phrasing every client /// uses for both the kill refusal and the retained list, so the two /// cannot describe the same tree differently. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let parts = [ (self.uncommitted_files, "uncommitted file"), (self.untracked_files, "untracked file"), (self.unpushed_commits, "unpushed commit"), ]; let said: Vec = parts .into_iter() .filter(|(n, _)| *n > 0) .map(|(n, unit)| { if n == 1 { format!("1 {unit}") } else { format!("{n} {unit}s") } }) .collect(); if said.is_empty() { return write!(f, "no unsaved work"); } write!(f, "{}", said.join(", ")) } } /// HTTP `POST /api/sessions` body. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CreateSessionRequest { pub cwd: PathBuf, pub command: String, #[serde(default)] pub env: HashMap, #[serde(default)] pub metadata: HashMap, #[serde(default)] pub worktree: Option, } /// Result of `GET /api/dirs`: the immediate subdirectories of one directory. /// Drives the spawn form's working-directory autocomplete (NAP-329). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DirListing { /// The resolved absolute directory that was listed, after the daemon /// expanded `~`/relative/empty inputs against its own home dir. The client /// uses it to know what an abbreviated query resolved to. pub path: PathBuf, /// Immediate subdirectory names (not full paths), sorted case-insensitively. /// Directories only — the spawn form's targets are always dirs. Hidden /// directories (dotfiles) are excluded. pub entries: Vec, /// True when `entries` was truncated at the daemon's cap; the client can /// surface "narrow your search" rather than implying the dir is small. #[serde(default)] pub truncated: bool, } /// One deduplicated recent working directory for the spawn form's "recent /// dirs" picker (#24/#26). Newest use of a `cwd`, with the command last run /// there. Mirrors the daemon-local `spawn_history::RecentDir`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RecentDir { pub cwd: PathBuf, pub last_command: String, pub last_used: DateTime, } /// Result of `GET /api/recent-dirs`: recently-used working dirs, newest-first /// and deduped by `cwd` (#26). `truncated` mirrors [`DirListing`]: true when /// the daemon capped the list. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RecentDirs { pub dirs: Vec, #[serde(default)] pub truncated: bool, } /// Result of a `GET /api/gitroot` probe: the enclosing git repository toplevel /// of a path, or `None` when the path isn't inside a repo (#26). Drives the /// spawn form's worktree section — a present `root` is the inferred /// `base_repo`; `None` disables the worktree toggle. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct GitRoot { #[serde(default, skip_serializing_if = "Option::is_none")] pub root: Option, } /// One discovered git repository (or linked worktree) for `GET /api/projects` /// (#54). The daemon reports the timestamp; clients decide what "stale" means /// (the web UI dims entries older than 7 days). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Project { /// Absolute path of the repository/worktree toplevel. pub path: PathBuf, /// Basename of `path`. pub name: String, #[serde(default)] pub is_worktree: bool, /// The main checkout this worktree belongs to. Set only when /// [`Self::is_worktree`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub base_repo: Option, /// Later of the repo's git HEAD mtime and the newest spawn-history use of /// any directory inside it. `None` when neither is known. #[serde(default, skip_serializing_if = "Option::is_none")] pub last_activity: Option>, /// Command last spawned in this project, from spawn history. #[serde(default, skip_serializing_if = "Option::is_none")] pub last_command: Option, } /// One local branch of a repository for `GET /api/branches` (#55). A client /// disables the entry when `is_head` is set or `worktree` is present — git /// refuses to check the same branch out in two worktrees at once. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct BranchInfo { /// Short branch name (`main`, `feature/foo`). pub name: String, /// Whether this branch is the main checkout's current HEAD. The main /// checkout's occupancy reports here rather than through `worktree`. pub is_head: bool, /// Toplevel path of the linked worktree holding this branch, if any. #[serde(default, skip_serializing_if = "Option::is_none")] pub worktree: Option, } /// Structured failure used everywhere we don't want to leak a string-only error. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ErrorInfo { pub code: String, pub message: String, /// Set only by the kill guard (#79): the unsaved work that blocked /// `DELETE /api/sessions/:id`, so a client can render counts directly. #[serde(default, skip_serializing_if = "Option::is_none")] pub unsaved_work: Option, } /// The envelope every `/api/*` failure is wrapped in: `{"error": {…}}`. /// /// The daemon builds it, the CLI and TUI decode it to surface the daemon's own /// message, and `maestro-web` uses it both to report proxy failures and to /// recover the rejection reason from an upstream WebSocket handshake. Before it /// lived here that shape was hand-rolled in four places, one of them by /// escaping quotes into a format string. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ApiError { pub error: ErrorInfo, } impl ApiError { pub fn new(code: impl Into, message: impl Into) -> Self { Self { error: ErrorInfo { code: code.into(), message: message.into(), unsaved_work: None, }, } } /// The daemon's human-readable reason, for clients that only render text. pub fn message(&self) -> &str { &self.error.message } } /// One command tile offered by the spawn form. /// /// Lives here rather than in `maestro-config` because it is on the wire inside /// [`DaemonInfo`]; `maestro-config` re-exports it so the on-disk `[spawn]` /// schema and the payload stay one type. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SpawnPreset { pub id: String, pub label: String, pub command: String, } /// Payload of `GET /api/info` — daemon identity, protocol version, and the /// spawn presets clients render as one-click tiles. /// /// Previously declared three times (daemon, CLI, TypeScript), and the CLI's /// copy silently dropped `version` and `presets`. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct DaemonInfo { #[serde(default)] pub hostname: String, /// The `PROTOCOL_VERSION` the daemon implements. #[serde(default)] pub version: String, /// Home directory of the OS user the daemon runs as, so clients can prefill /// a spawn form's working directory. Empty if unresolved. #[serde(default)] pub home_dir: String, #[serde(default)] pub presets: Vec, } /// Payload of `GET /api/names?count=N` (#53) — a batch of fresh /// `adjective-noun` session handles generated by the `maestro-names` crate. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct NameBatch { pub names: Vec, } // --------------------------------------------------------------------------- // Catalog events (`WS /api/events`) // --------------------------------------------------------------------------- /// Messages the daemon broadcasts to clients over `WS /api/events`. /// /// A subscriber receives a single `Snapshot` immediately on connect, then a /// stream of deltas as sessions are created, updated, and killed. Skipped /// events (broadcast channel lag) surface as `Lagged`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type")] pub enum CatalogEvent { Snapshot { sessions: Vec, }, SessionAdded { session: Session, }, SessionRemoved { id: Uuid, }, SessionUpdated { session: Session, }, /// Server-issued notification that the broadcast queue dropped frames. /// Clients should re-fetch a full snapshot. Lagged { skipped: u64, }, } // --------------------------------------------------------------------------- // Client ↔ Daemon control messages (text frames on the attach WS) // --------------------------------------------------------------------------- /// Text-frame control messages on a `WS /api/attach/:id` connection. /// PTY bytes travel in binary frames and are not modeled here. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type")] pub enum ClientControl { Resize { cols: u16, rows: u16 }, Ping, Pong, Error(ErrorInfo), } #[cfg(test)] mod tests { use super::*; #[test] fn version_compatibility_is_major_only() { assert!(is_compatible(PROTOCOL_VERSION)); let ours_major = PROTOCOL_VERSION.split('.').next().unwrap(); assert!(is_compatible(&format!("{ours_major}.99.99"))); assert!(!is_compatible("99.0.0")); } /// A daemon predating the field sends nothing; that must not read as a /// mismatch. #[test] fn version_compatibility_tolerates_unknown_versions() { assert!(is_compatible("")); assert!(is_compatible("unknown")); } fn round_trip(value: &T) -> T where T: Serialize + for<'de> Deserialize<'de>, { let json = serde_json::to_string(value).expect("serialize"); serde_json::from_str(&json).expect("deserialize") } #[test] fn project_round_trips_and_defaults_on_legacy_payload() { let full = Project { path: PathBuf::from("/home/u/tea/maestro"), name: "maestro".into(), is_worktree: true, base_repo: Some(PathBuf::from("/home/u/tea/maestro-main")), last_activity: Some( DateTime::parse_from_rfc3339("2026-08-23T12:00:00Z") .unwrap() .with_timezone(&Utc), ), last_command: Some("claude".into()), }; assert_eq!(full, round_trip(&full)); // A payload from a daemon predating the optional fields still decodes. let legacy = serde_json::json!({ "path": "/home/u/proj", "name": "proj", }); let p: Project = serde_json::from_value(legacy).unwrap(); assert!(!p.is_worktree); assert_eq!(p.base_repo, None); assert_eq!(p.last_activity, None); assert_eq!(p.last_command, None); } fn sample_session() -> Session { Session { id: Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef), cwd: PathBuf::from("/home/naps62/projects/maestro"), command: "claude".into(), env: HashMap::from([("FOO".into(), "bar".into())]), metadata: HashMap::from([("name".into(), Value::String("maestro-impl".into()))]), worktree: None, status: SessionStatus::Running, created_at: DateTime::parse_from_rfc3339("2026-05-24T12:00:00Z") .unwrap() .with_timezone(&Utc), foreground: Some("claude".into()), activity: ActivityState::Active, last_activity_at: DateTime::parse_from_rfc3339("2026-05-24T12:05:00Z") .unwrap() .with_timezone(&Utc), current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")), blocked_work: None, } } #[test] fn session_activity_fields_default_on_legacy_payload() { // A pre-NAP-340 daemon omits the activity fields; they must deserialize // to the forward-compat defaults rather than failing. let json = serde_json::json!({ "id": Uuid::from_u128(1), "cwd": "/tmp", "command": "bash", "status": "Running", "created_at": "2026-05-24T12:00:00Z" }); let s: Session = serde_json::from_value(json).unwrap(); assert!(s.foreground.is_none()); assert_eq!(s.activity, ActivityState::Idle); assert_eq!(s.last_activity_at, epoch()); assert!(s.current_cwd.is_none()); assert!(s.blocked_work.is_none()); } #[test] fn blocked_work_defaults_to_none_on_legacy_payload() { // A pre-#83 daemon omits the indicator; it must deserialize to `None` // rather than failing, and a present value round-trips. let json = serde_json::json!({ "id": Uuid::from_u128(1), "cwd": "/tmp", "command": "bash", "status": "Running", "created_at": "2026-05-24T12:00:00Z" }); let s: Session = serde_json::from_value(json).unwrap(); assert_eq!(s.blocked_work, None); let mut with_blocker = sample_session(); with_blocker.blocked_work = Some(InTheWay { uncommitted_files: 3, ..InTheWay::default() }); assert_eq!( with_blocker.clone().blocked_work, serde_json::from_value::(serde_json::to_value(&with_blocker).unwrap()) .unwrap() .blocked_work ); } #[test] fn activity_state_serializes_pascal_case_and_defaults_idle() { assert_eq!( serde_json::to_value(ActivityState::AwaitingInput).unwrap(), serde_json::json!("AwaitingInput") ); assert_eq!(ActivityState::default(), ActivityState::Idle); } #[test] fn session_round_trips() { let s = sample_session(); assert_eq!(s, round_trip(&s)); } #[test] fn session_status_serializes_pascal_case() { assert_eq!( serde_json::to_value(SessionStatus::Running).unwrap(), serde_json::json!("Running") ); assert_eq!( serde_json::to_value(SessionStatus::Dead).unwrap(), serde_json::json!("Dead") ); } #[test] fn create_session_request_with_worktree_round_trips() { let req = CreateSessionRequest { cwd: PathBuf::from("/work"), command: "bash".into(), env: HashMap::new(), metadata: HashMap::new(), worktree: Some(WorktreeSpec { base_repo: PathBuf::from("/home/naps62/projects/maestro"), branch: "feat/foo".into(), create_branch: true, }), }; assert_eq!(req, round_trip(&req)); } #[test] fn create_session_request_omits_optional_worktree() { let json = serde_json::json!({ "cwd": "/work", "command": "bash" }); let req: CreateSessionRequest = serde_json::from_value(json).unwrap(); assert!(req.worktree.is_none()); assert!(req.env.is_empty()); assert!(req.metadata.is_empty()); } /// A client that predates the new/existing discriminator sends no /// `create_branch`; the daemon must read that as "create a new branch" — /// exactly what every payload meant before the field existed (#56). #[test] fn worktree_spec_defaults_on_legacy_payload() { let json = serde_json::json!({ "base_repo": "/home/naps62/projects/maestro", "branch": "feat/foo", }); let spec: WorktreeSpec = serde_json::from_value(json).unwrap(); assert!(spec.create_branch); } #[test] fn worktree_spec_round_trips_existing_branch_mode() { let spec = WorktreeSpec { base_repo: PathBuf::from("/work"), branch: "feat/foo".into(), create_branch: false, }; assert_eq!(spec, round_trip(&spec)); } fn sample_retained_worktree() -> RetainedWorktree { RetainedWorktree { id: Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef), path: PathBuf::from("/work/maestro/worktrees/feat/login"), base_repo: PathBuf::from("/work/maestro"), branch: "feat/login".into(), reason: RetainedReason::ForceKilled, retained_at: DateTime::parse_from_rfc3339("2026-08-24T10:00:00Z") .unwrap() .with_timezone(&Utc), in_the_way: InTheWay { uncommitted_files: 2, untracked_files: 1, unpushed_commits: 3, }, } } #[test] fn retained_worktree_round_trips() { let wt = sample_retained_worktree(); assert_eq!(wt, round_trip(&wt)); } /// A payload from a daemon predating `in_the_way` still decodes; the /// breakdown defaults to "nothing in the way". #[test] fn retained_worktree_defaults_in_the_way_on_legacy_payload() { let legacy = serde_json::json!({ "id": Uuid::from_u128(1), "path": "/w/t", "base_repo": "/w", "branch": "feat/login", "reason": "force-killed", "retained_at": "2026-08-24T10:00:00Z", }); let wt: RetainedWorktree = serde_json::from_value(legacy).unwrap(); assert_eq!(wt.reason, RetainedReason::ForceKilled); assert_eq!(wt.in_the_way, InTheWay::default()); assert!(wt.in_the_way.is_clear()); } #[test] fn retained_reason_serializes_kebab_case() { assert_eq!( serde_json::to_value(RetainedReason::ForceKilled).unwrap(), serde_json::json!("force-killed") ); assert_eq!( serde_json::to_value(RetainedReason::ExitedWithUnsavedWork).unwrap(), serde_json::json!("exited-with-unsaved-work") ); } #[test] fn kill_outcome_round_trips_a_retained_worktree() { let outcome = KillOutcome { retained: Some(sample_retained_worktree()), }; assert_eq!(outcome, round_trip(&outcome)); } /// A kill with nothing to retain — no worktree, or a clean one removed as /// usual — decodes from an absent field. #[test] fn kill_outcome_defaults_to_nothing_retained() { let outcome: KillOutcome = serde_json::from_value(serde_json::json!({})).unwrap(); assert_eq!(outcome.retained, None); } #[test] fn catalog_event_round_trips_and_tags() { let session = sample_session(); let variants = vec![ CatalogEvent::Snapshot { sessions: vec![session.clone()], }, CatalogEvent::SessionAdded { session: session.clone(), }, CatalogEvent::SessionRemoved { id: session.id }, CatalogEvent::SessionUpdated { session }, CatalogEvent::Lagged { skipped: 3 }, ]; for v in variants { assert_eq!(v, round_trip(&v), "variant did not round-trip"); } let v = serde_json::to_value(CatalogEvent::Lagged { skipped: 3 }).unwrap(); assert_eq!(v["type"], "Lagged"); assert_eq!(v["skipped"], 3); } #[test] fn client_control_round_trips_and_tags() { let resize = ClientControl::Resize { cols: 120, rows: 40, }; let v = serde_json::to_value(&resize).unwrap(); assert_eq!(v["type"], "Resize"); assert_eq!(v["cols"], 120); assert_eq!(v["rows"], 40); for c in [ ClientControl::Ping, ClientControl::Pong, ClientControl::Error(ErrorInfo { code: "bad_frame".into(), message: "decode failed".into(), unsaved_work: None, }), resize, ] { assert_eq!(c, round_trip(&c)); } } }