Skip to main content

agentmux_srv/backend/
obj.rs

1// Copyright 2025, Command Line Inc.
2// Copyright 2025-2026, AgentMux Corp.
3// SPDX-License-Identifier: Apache-2.0
4
5//! StoreObj types: Rust equivalents of Go structs from pkg/obj/wtype.go.
6//! All `#[serde(rename = "...")]` tags match Go JSON tags for wire compatibility.
7
8
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12use super::oref::ORef;
13
14// ---- Custom serde for MetaMapType ----
15// Go serializes nil maps as `null` and initialized maps as `{}`.
16// Rust's HashMap is always initialized (empty = `{}`), so we need
17// to serialize empty HashMap as `null` to match Go's wire format.
18// We also need to accept `null` on deserialization (from DB or network).
19fn serialize_meta_as_null_if_empty<S>(meta: &MetaMapType, serializer: S) -> Result<S::Ok, S::Error>
20where
21    S: serde::Serializer,
22{
23    if meta.is_empty() {
24        serializer.serialize_none()
25    } else {
26        meta.serialize(serializer)
27    }
28}
29
30fn deserialize_meta_or_null<'de, D>(deserializer: D) -> Result<MetaMapType, D::Error>
31where
32    D: serde::Deserializer<'de>,
33{
34    Option::<MetaMapType>::deserialize(deserializer).map(|opt| opt.unwrap_or_default())
35}
36
37// ---- OType constants (match Go's obj.OType_* constants) ----
38
39pub const OTYPE_CLIENT: &str = "client";
40pub const OTYPE_WINDOW: &str = "window";
41pub const OTYPE_WORKSPACE: &str = "workspace";
42pub const OTYPE_TAB: &str = "tab";
43pub const OTYPE_LAYOUT: &str = "layout";
44pub const OTYPE_BLOCK: &str = "block";
45pub const OTYPE_TEMP: &str = "temp";
46
47pub const VALID_OTYPES: &[&str] = &[
48    OTYPE_CLIENT,
49    OTYPE_WINDOW,
50    OTYPE_WORKSPACE,
51    OTYPE_TAB,
52    OTYPE_LAYOUT,
53    OTYPE_BLOCK,
54    OTYPE_TEMP,
55];
56
57// ---- MetaMapType ----
58
59/// Matches Go's `MetaMapType = map[string]any`.
60pub type MetaMapType = HashMap<String, serde_json::Value>;
61
62/// Merge `update` into `base`, matching Go's `MergeMeta` logic.
63/// - Keys ending in `:*` with truthy value clear the section.
64/// - `null` values delete the key.
65/// - If `merge_special` is false, keys starting with `display:` are skipped.
66pub fn merge_meta(base: &MetaMapType, update: &MetaMapType, merge_special: bool) -> MetaMapType {
67    let mut result = base.clone();
68
69    // First pass: handle "section:*" clear keys
70    for (k, v) in update {
71        if !k.ends_with(":*") {
72            continue;
73        }
74        // Check if value is truthy (bool true)
75        let is_true = matches!(v, serde_json::Value::Bool(true));
76        if !is_true {
77            continue;
78        }
79        let prefix = k.trim_end_matches(":*");
80        if prefix.is_empty() {
81            continue;
82        }
83        let prefix_colon = format!("{prefix}:");
84        result.retain(|k2, _| k2 != prefix && !k2.starts_with(&prefix_colon));
85    }
86
87    // Second pass: merge regular keys
88    for (k, v) in update {
89        if !merge_special && k.starts_with("display:") {
90            continue;
91        }
92        if k.ends_with(":*") {
93            continue;
94        }
95        if v.is_null() {
96            result.remove(k);
97            continue;
98        }
99        result.insert(k.clone(), v.clone());
100    }
101
102    result
103}
104
105/// Helper to get a string value from MetaMapType.
106pub fn meta_get_string(meta: &MetaMapType, key: &str, default: &str) -> String {
107    meta.get(key)
108        .and_then(|v| v.as_str())
109        .unwrap_or(default)
110        .to_string()
111}
112
113/// The agent id a block's meta names: `agentId`, else the legacy `agent:id`.
114/// Not trimmed; a string `agentId`, even an empty one, wins over the legacy key.
115pub fn block_meta_agent_id(meta: &MetaMapType) -> Option<&str> {
116    meta.get("agentId")
117        .and_then(|v| v.as_str())
118        .or_else(|| meta.get("agent:id").and_then(|v| v.as_str()))
119}
120
121/// Helper to get a bool value from MetaMapType.
122pub fn meta_get_bool(meta: &MetaMapType, key: &str, default: bool) -> bool {
123    meta.get(key).and_then(|v| v.as_bool()).unwrap_or(default)
124}
125
126// ---- StoreObj trait ----
127
128/// Rust equivalent of Go's `StoreObj` interface.
129/// Every wave object has an otype, an OID, a version, and metadata.
130pub trait StoreObj: Serialize + for<'de> Deserialize<'de> {
131    fn get_otype() -> &'static str;
132    fn get_oid(&self) -> &str;
133    #[allow(dead_code)]
134    fn set_oid(&mut self, oid: String);
135    #[allow(dead_code)]
136    fn get_version(&self) -> i64;
137    fn set_version(&mut self, version: i64);
138    #[allow(dead_code)]
139    fn get_meta(&self) -> &MetaMapType;
140    #[allow(dead_code)]
141    fn set_meta(&mut self, meta: MetaMapType);
142
143    #[allow(dead_code)]
144    fn oref(&self) -> ORef {
145        ORef::new(Self::get_otype(), self.get_oid())
146    }
147}
148
149/// Macro that implements `StoreObj` for a struct that has standard fields:
150/// `oid: String`, `version: i64`, `meta: MetaMapType`.
151macro_rules! impl_mux_obj {
152    ($ty:ty, $otype:expr) => {
153        impl StoreObj for $ty {
154            fn get_otype() -> &'static str {
155                $otype
156            }
157            fn get_oid(&self) -> &str {
158                &self.oid
159            }
160            fn set_oid(&mut self, oid: String) {
161                self.oid = oid;
162            }
163            fn get_version(&self) -> i64 {
164                self.version
165            }
166            fn set_version(&mut self, version: i64) {
167                self.version = version;
168            }
169            fn get_meta(&self) -> &MetaMapType {
170                &self.meta
171            }
172            fn set_meta(&mut self, meta: MetaMapType) {
173                self.meta = meta;
174            }
175        }
176    };
177}
178
179// ---- Update types ----
180
181pub const UPDATE_TYPE_UPDATE: &str = "update";
182#[allow(dead_code)]
183pub const UPDATE_TYPE_DELETE: &str = "delete";
184
185// ---- UIContext ----
186
187#[allow(dead_code)]
188#[derive(Debug, Clone, Serialize, Deserialize, Default)]
189pub struct UIContext {
190    #[serde(rename = "windowid")]
191    pub window_id: String,
192    #[serde(rename = "activetabid")]
193    pub active_tab_id: String,
194}
195
196// ---- Point / WinSize / TermSize ----
197
198#[derive(Debug, Clone, Serialize, Deserialize, Default)]
199pub struct Point {
200    pub x: i64,
201    pub y: i64,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize, Default)]
205pub struct WinSize {
206    pub width: i64,
207    pub height: i64,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize, Default)]
211pub struct TermSize {
212    pub rows: i64,
213    pub cols: i64,
214}
215
216// ---- RuntimeOpts ----
217
218#[derive(Debug, Clone, Serialize, Deserialize, Default)]
219pub struct RuntimeOpts {
220    #[serde(default, skip_serializing_if = "is_default_term_size")]
221    pub termsize: TermSize,
222    #[serde(default, skip_serializing_if = "is_default_win_size")]
223    pub winsize: WinSize,
224}
225
226fn is_default_term_size(ts: &TermSize) -> bool {
227    ts.rows == 0 && ts.cols == 0
228}
229fn is_default_win_size(ws: &WinSize) -> bool {
230    ws.width == 0 && ws.height == 0
231}
232
233// ---- FileDef / BlockDef ----
234
235#[derive(Debug, Clone, Serialize, Deserialize, Default)]
236pub struct FileDef {
237    #[serde(default, skip_serializing_if = "String::is_empty")]
238    pub content: String,
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub meta: Option<HashMap<String, serde_json::Value>>,
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize, Default)]
244pub struct BlockDef {
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub files: Option<HashMap<String, FileDef>>,
247    #[serde(default, skip_serializing_if = "MetaMapType::is_empty")]
248    pub meta: MetaMapType,
249}
250
251// ---- StickerType ----
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct StickerClickOpts {
255    #[serde(default, skip_serializing_if = "String::is_empty")]
256    pub sendinput: String,
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub createblock: Option<BlockDef>,
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize)]
262pub struct StickerDisplayOpts {
263    #[serde(default)]
264    pub icon: String,
265    #[serde(default)]
266    pub imgsrc: String,
267    #[serde(default, skip_serializing_if = "String::is_empty")]
268    pub svgblob: String,
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct StickerType {
273    pub stickertype: String,
274    pub style: HashMap<String, serde_json::Value>,
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub clickopts: Option<StickerClickOpts>,
277    pub display: StickerDisplayOpts,
278}
279
280// ---- LayoutActionData / LeafOrderEntry ----
281
282#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
283pub struct LayoutActionData {
284    pub actiontype: String,
285    pub actionid: String,
286    pub blockid: String,
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub nodesize: Option<u32>,
289    /// Split-only: the new node's desired size as a fraction (0.0-1.0) of the
290    /// TARGET node's current size, e.g. 0.5 for a 50/50 inner-direction drop,
291    /// 0.2 for an outer-direction drop (matches the ghost's `leaf/5` band).
292    /// The frontend applies this against the target's live `size` at split
293    /// time (`layoutTree.ts:splitHorizontal/splitVertical`), which the
294    /// backend cannot see. Takes precedence over `nodesize` when present.
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub nodesizefraction: Option<f64>,
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub indexarr: Option<Vec<i32>>,
299    #[serde(default)]
300    pub focused: bool,
301    #[serde(default)]
302    pub magnified: bool,
303    #[serde(default)]
304    pub ephemeral: bool,
305    #[serde(default, skip_serializing_if = "String::is_empty")]
306    pub targetblockid: String,
307    #[serde(default, skip_serializing_if = "String::is_empty")]
308    pub position: String,
309}
310
311#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
312pub struct LeafOrderEntry {
313    pub nodeid: String,
314    pub blockid: String,
315}
316
317// ====================================================================
318// Core StoreObj types — each matches the Go struct + JSON tags exactly
319// ====================================================================
320
321/// Go: `Client` in pkg/obj/wtype.go
322#[derive(Debug, Clone, Serialize, Deserialize, Default)]
323pub struct Client {
324    pub oid: String,
325    pub version: i64,
326    #[serde(default)]
327    pub windowids: Vec<String>,
328    #[serde(default, serialize_with = "serialize_meta_as_null_if_empty", deserialize_with = "deserialize_meta_or_null")]
329    pub meta: MetaMapType,
330    #[serde(default, skip_serializing_if = "is_zero_i64")]
331    pub tosagreed: i64,
332    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
333    pub hasoldhistory: bool,
334    #[serde(default, skip_serializing_if = "String::is_empty")]
335    pub tempoid: String,
336}
337
338impl_mux_obj!(Client, OTYPE_CLIENT);
339
340/// Go: `Window` in pkg/obj/wtype.go
341#[derive(Debug, Clone, Serialize, Deserialize, Default)]
342pub struct Window {
343    pub oid: String,
344    pub version: i64,
345    #[serde(default)]
346    pub workspaceid: String,
347    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
348    pub isnew: bool,
349    #[serde(default)]
350    pub pos: Point,
351    #[serde(default)]
352    pub winsize: WinSize,
353    #[serde(default)]
354    pub lastfocusts: i64,
355    /// SPEC_PILLAR1_STEP2 — the host's last-set opacity for this window
356    /// (`0.0..=1.0`), so a crashed/restarted host can restore it instead
357    /// of defaulting to fully opaque. `None` = never set / fully opaque.
358    /// Written only via the `SetWindowOpacity` RPC (a direct store
359    /// read-modify-write, not a reducer-dispatched Command — see
360    /// `server::service::window::handle_window_service`); the host is
361    /// the sole source of truth for the live value, this is a durable
362    /// mirror.
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub opacity: Option<f32>,
365    /// SPEC_PILLAR1_STEP3 — the window's kind (`"full_instance"` |
366    /// `"subwindow"`), durable so a reproject can tell which native-window
367    /// creation path to drive. `None` = unknown/legacy row; treat as
368    /// `full_instance` on read (matches today's implicit default — every
369    /// window was a full instance before this field existed). Written only
370    /// via the `SetWindowTopology` RPC (a direct store read-modify-write,
371    /// not reducer-dispatched — window kind/parent were never reducer
372    /// state, same rationale as `opacity` above).
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub kind: Option<String>,
375    /// SPEC_PILLAR1_STEP3 — the parent `Window.oid` for a `subwindow`;
376    /// `None` for a `full_instance` (or an unset/legacy row). A `subwindow`
377    /// with `parent_window_id: None` is a nonsensical state the
378    /// `SetWindowTopology` RPC rejects at write time.
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub parent_window_id: Option<String>,
381    #[serde(default, serialize_with = "serialize_meta_as_null_if_empty", deserialize_with = "deserialize_meta_or_null")]
382    pub meta: MetaMapType,
383}
384
385impl_mux_obj!(Window, OTYPE_WINDOW);
386
387/// Go: `Workspace` in pkg/obj/wtype.go
388#[derive(Debug, Clone, Serialize, Deserialize, Default)]
389pub struct Workspace {
390    pub oid: String,
391    pub version: i64,
392    #[serde(default, skip_serializing_if = "String::is_empty")]
393    pub name: String,
394    #[serde(default)]
395    pub tabids: Vec<String>,
396    #[serde(default)]
397    pub pinnedtabids: Vec<String>,
398    #[serde(default)]
399    pub activetabid: String,
400    #[serde(default, serialize_with = "serialize_meta_as_null_if_empty", deserialize_with = "deserialize_meta_or_null")]
401    pub meta: MetaMapType,
402}
403
404impl_mux_obj!(Workspace, OTYPE_WORKSPACE);
405
406/// Go: `WorkspaceListEntry` in pkg/obj/wtype.go
407/// Used by ListWorkspaces — returns just {workspaceid, windowid}, not full workspace objects.
408#[derive(Debug, Clone, Serialize, Deserialize)]
409pub struct WorkspaceListEntry {
410    pub workspaceid: String,
411    #[serde(default)]
412    pub windowid: String,
413}
414
415/// Go: `Tab` in pkg/obj/wtype.go
416#[derive(Debug, Clone, Serialize, Deserialize, Default)]
417pub struct Tab {
418    pub oid: String,
419    pub version: i64,
420    #[serde(default)]
421    pub name: String,
422    #[serde(default)]
423    pub layoutstate: String,
424    #[serde(default)]
425    pub blockids: Vec<String>,
426    #[serde(default, serialize_with = "serialize_meta_as_null_if_empty", deserialize_with = "deserialize_meta_or_null")]
427    pub meta: MetaMapType,
428}
429
430impl_mux_obj!(Tab, OTYPE_TAB);
431
432// ====================================================================
433// Phase E.4.B Phase 3 — LayoutNode, LayoutNodeData, FlexDirection now
434// live in agentmux-common so they can be referenced by ipc.rs Command/
435// Event variants without a circular dependency. Re-exported here so
436// all existing callers within agentmux-srv keep working unchanged.
437// ====================================================================
438pub use agentmux_common::{FlexDirection, LayoutNode, LayoutNodeData};
439
440/// Go: `LayoutState` in pkg/obj/wtype.go
441#[derive(Debug, Clone, Serialize, Deserialize, Default)]
442pub struct LayoutState {
443    pub oid: String,
444    pub version: i64,
445    /// Phase E.4.B Phase 2 — typed; was `Option<serde_json::Value>`.
446    /// Wire format unchanged (serde-compat with the prior JSON shape).
447    #[serde(default, skip_serializing_if = "Option::is_none")]
448    pub rootnode: Option<LayoutNode>,
449    #[serde(default, skip_serializing_if = "String::is_empty")]
450    pub magnifiednodeid: String,
451    #[serde(default, skip_serializing_if = "String::is_empty")]
452    pub focusednodeid: String,
453    #[serde(default, skip_serializing_if = "Option::is_none")]
454    pub leaforder: Option<Vec<LeafOrderEntry>>,
455    #[serde(default, skip_serializing_if = "Option::is_none")]
456    pub pendingbackendactions: Option<Vec<LayoutActionData>>,
457    #[serde(default, skip_serializing_if = "Option::is_none")]
458    pub meta: Option<MetaMapType>,
459}
460
461// LayoutState has meta as Option<MetaMapType> (Go uses omitempty), so manual impl:
462impl StoreObj for LayoutState {
463    fn get_otype() -> &'static str {
464        OTYPE_LAYOUT
465    }
466    fn get_oid(&self) -> &str {
467        &self.oid
468    }
469    fn set_oid(&mut self, oid: String) {
470        self.oid = oid;
471    }
472    fn get_version(&self) -> i64 {
473        self.version
474    }
475    fn set_version(&mut self, version: i64) {
476        self.version = version;
477    }
478    fn get_meta(&self) -> &MetaMapType {
479        static EMPTY: std::sync::LazyLock<MetaMapType> = std::sync::LazyLock::new(MetaMapType::new);
480        self.meta.as_ref().unwrap_or(&EMPTY)
481    }
482    fn set_meta(&mut self, meta: MetaMapType) {
483        self.meta = Some(meta);
484    }
485}
486
487/// Go: `Block` in pkg/obj/wtype.go
488#[derive(Debug, Clone, Serialize, Deserialize, Default)]
489pub struct Block {
490    pub oid: String,
491    #[serde(default, skip_serializing_if = "String::is_empty")]
492    pub parentoref: String,
493    pub version: i64,
494    #[serde(default, skip_serializing_if = "Option::is_none")]
495    pub runtimeopts: Option<RuntimeOpts>,
496    #[serde(default, skip_serializing_if = "Option::is_none")]
497    pub stickers: Option<Vec<StickerType>>,
498    #[serde(default, serialize_with = "serialize_meta_as_null_if_empty", deserialize_with = "deserialize_meta_or_null")]
499    pub meta: MetaMapType,
500    #[serde(default, skip_serializing_if = "Option::is_none")]
501    pub subblockids: Option<Vec<String>>,
502}
503
504impl_mux_obj!(Block, OTYPE_BLOCK);
505
506// ---- MuxObjUpdate ----
507
508/// Represents an update notification for a wave object.
509/// Matches Go's `MuxObjUpdate`.
510#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
511#[ts(export, export_to = "../../../frontend/types/rpc/")]
512pub struct MuxObjUpdate {
513    pub updatetype: String,
514    pub otype: String,
515    pub oid: String,
516    /// The object, for an update; absent for a delete.
517    #[serde(default, skip_serializing_if = "Option::is_none")]
518    #[ts(optional, type = "MuxObj")]
519    pub obj: Option<serde_json::Value>,
520}
521
522/// The `waveobj:batchedupdates` payload: the updates of one atomic backend transition, in order.
523#[derive(Debug, Clone, Serialize, ts_rs::TS)]
524#[ts(export, export_to = "../../../frontend/types/rpc/")]
525pub struct MuxObjBatch(pub Vec<MuxObjUpdate>);
526
527// ---- Helpers ----
528
529use crate::backend::rpc_types::is_zero_i64;
530
531/// Serialize any StoreObj to JSON bytes, including the "otype" field.
532/// This matches Go's `obj.ToJson()`.
533pub fn mux_obj_to_json<T: StoreObj>(obj: &T) -> Result<Vec<u8>, serde_json::Error> {
534    let mut map = serde_json::to_value(obj)?;
535    if let Some(m) = map.as_object_mut() {
536        m.insert("otype".to_string(), serde_json::Value::String(T::get_otype().to_string()));
537    }
538    serde_json::to_vec(&map)
539}
540
541/// Serialize any StoreObj to a serde_json::Value, including the "otype" field.
542/// This matches Go's `obj.ToJsonMap()` — used by GetObject/GetObjects responses.
543pub fn mux_obj_to_value<T: StoreObj>(obj: &T) -> serde_json::Value {
544    let mut map = serde_json::to_value(obj).unwrap_or_default();
545    if let Some(m) = map.as_object_mut() {
546        m.insert("otype".to_string(), serde_json::Value::String(T::get_otype().to_string()));
547    }
548    map
549}
550
551/// Deserialize JSON bytes to a specific StoreObj type.
552/// Does NOT validate the otype field — caller should verify if needed.
553pub fn mux_obj_from_json<T: StoreObj>(data: &[u8]) -> Result<T, serde_json::Error> {
554    serde_json::from_slice(data)
555}
556
557// ====================================================================
558// Tests
559// ====================================================================
560
561#[cfg(test)]
562mod tests {
563    #[test]
564    fn block_meta_agent_id_prefers_agent_id_over_the_legacy_key() {
565        use super::{block_meta_agent_id, MetaMapType};
566        let meta = |pairs: &[(&str, serde_json::Value)]| -> MetaMapType {
567            pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
568        };
569        let both = meta(&[("agentId", "new".into()), ("agent:id", "old".into())]);
570        assert_eq!(block_meta_agent_id(&both), Some("new"));
571        let legacy = meta(&[("agent:id", "old".into())]);
572        assert_eq!(block_meta_agent_id(&legacy), Some("old"));
573        let non_string = meta(&[("agentId", 7.into()), ("agent:id", "old".into())]);
574        assert_eq!(block_meta_agent_id(&non_string), Some("old"));
575        let empty = meta(&[("agentId", "".into()), ("agent:id", "old".into())]);
576        assert_eq!(block_meta_agent_id(&empty), Some(""));
577        assert_eq!(block_meta_agent_id(&MetaMapType::new()), None);
578    }
579
580    /// This crate defines `BlockDef` and `FileDef` TWICE — here, and again in
581    /// `backend/wconfig/types.rs` for widgets. The pairs differ in Rust:
582    /// `obj` uses `Option<HashMap<..>>` / `Option<..>` where `wconfig` uses a
583    /// plain `HashMap`. They are nonetheless wire-identical, because every
584    /// field on both carries `skip_serializing_if`, so an absent map and an
585    /// empty one both vanish.
586    ///
587    /// That is worth pinning rather than assuming, for two reasons.
588    ///
589    /// The frontend has ONE hand-written contract for each
590    /// (`frontend/types/srv-types.d.ts`, `type BlockDef = { files?, meta? }`,
591    /// still tagged `// waveobj.BlockDef` from the Go original). Both Rust
592    /// structs currently satisfy it. If either drifts — someone drops a
593    /// `skip_serializing_if`, or makes a field required — one of them starts
594    /// lying to that contract and nothing else would say so.
595    ///
596    /// And when the `websocket.rs` commands are eventually typed, two structs
597    /// sharing a name would generate two `BlockDef` TypeScript files into the same
598    /// export directory, one silently overwriting the other. Knowing the shapes
599    /// agree is the precondition for merging them, or for choosing either as
600    /// canonical. This test does not fix the duplication; it stops it becoming
601    /// a divergence while nobody is looking.
602    #[test]
603    fn block_def_and_file_def_serialize_identically_in_both_modules() {
604        use crate::backend::wconfig::types as wc;
605
606        // Empty: both must omit every field, not emit nulls or empty maps.
607        let a = serde_json::to_string(&super::BlockDef::default()).unwrap();
608        let b = serde_json::to_string(&wc::BlockDef::default()).unwrap();
609        assert_eq!(a, "{}", "obj::BlockDef default must serialize to {{}}");
610        assert_eq!(a, b, "the two BlockDef structs disagree on the empty case");
611
612        let a = serde_json::to_string(&super::FileDef::default()).unwrap();
613        let b = serde_json::to_string(&wc::FileDef::default()).unwrap();
614        assert_eq!(a, "{}", "obj::FileDef default must serialize to {{}}");
615        assert_eq!(a, b, "the two FileDef structs disagree on the empty case");
616
617        // Populated: same keys, same shape. Content is the only always-present
618        // scalar, so it is enough to prove the field names line up.
619        let mut oa = super::FileDef::default();
620        oa.content = "x".into();
621        let mut ob = wc::FileDef::default();
622        ob.content = "x".into();
623        assert_eq!(
624            serde_json::to_string(&oa).unwrap(),
625            serde_json::to_string(&ob).unwrap(),
626            "the two FileDef structs disagree once populated"
627        );
628    }
629
630    use super::*;
631
632    #[test]
633    fn test_client_roundtrip() {
634        let client = Client {
635            oid: "550e8400-e29b-41d4-a716-446655440000".to_string(),
636            version: 3,
637            windowids: vec!["w1".to_string(), "w2".to_string()],
638            meta: MetaMapType::new(),
639            tosagreed: 1700000000000,
640            ..Default::default()
641        };
642        let json = mux_obj_to_json(&client).unwrap();
643        let parsed: Client = mux_obj_from_json(&json).unwrap();
644        assert_eq!(parsed.oid, client.oid);
645        assert_eq!(parsed.version, client.version);
646        assert_eq!(parsed.windowids, client.windowids);
647        assert_eq!(parsed.tosagreed, client.tosagreed);
648    }
649
650    #[test]
651    fn test_window_roundtrip() {
652        let window = Window {
653            oid: "550e8400-e29b-41d4-a716-446655440001".to_string(),
654            version: 1,
655            workspaceid: "ws-123".to_string(),
656            pos: Point { x: 100, y: 200 },
657            winsize: WinSize {
658                width: 1920,
659                height: 1080,
660            },
661            lastfocusts: 1700000000000,
662            meta: MetaMapType::new(),
663            ..Default::default()
664        };
665        let json = mux_obj_to_json(&window).unwrap();
666        let parsed: Window = mux_obj_from_json(&json).unwrap();
667        assert_eq!(parsed.workspaceid, "ws-123");
668        assert_eq!(parsed.pos.x, 100);
669        assert_eq!(parsed.winsize.width, 1920);
670    }
671
672    #[test]
673    fn test_workspace_roundtrip() {
674        let ws = Workspace {
675            oid: "ws-oid".to_string(),
676            version: 2,
677            name: "My Workspace".to_string(),
678            tabids: vec!["t1".to_string(), "t2".to_string()],
679            pinnedtabids: vec!["t0".to_string()],
680            activetabid: "t1".to_string(),
681            meta: MetaMapType::new(),
682            ..Default::default()
683        };
684        let json = mux_obj_to_json(&ws).unwrap();
685        let parsed: Workspace = mux_obj_from_json(&json).unwrap();
686        assert_eq!(parsed.name, "My Workspace");
687        assert_eq!(parsed.tabids.len(), 2);
688        assert_eq!(parsed.pinnedtabids, vec!["t0"]);
689    }
690
691    #[test]
692    fn test_tab_roundtrip() {
693        let tab = Tab {
694            oid: "tab-oid".to_string(),
695            version: 1,
696            name: "Tab 1".to_string(),
697            layoutstate: "ls-123".to_string(),
698            blockids: vec!["b1".to_string(), "b2".to_string()],
699            meta: MetaMapType::new(),
700        };
701        let json = mux_obj_to_json(&tab).unwrap();
702        let parsed: Tab = mux_obj_from_json(&json).unwrap();
703        assert_eq!(parsed.name, "Tab 1");
704        assert_eq!(parsed.blockids.len(), 2);
705    }
706
707    #[test]
708    fn test_layout_state_roundtrip() {
709        // Phase E.4.B Phase 2 — uses typed LayoutNode (was a junk JSON blob).
710        let ls = LayoutState {
711            oid: "ls-oid".to_string(),
712            version: 1,
713            rootnode: Some(LayoutNode {
714                id: "node-1".into(),
715                flex_direction: FlexDirection::Row,
716                size: 1.0,
717                children: Vec::new(),
718                data: None,
719                ..Default::default()
720            }),
721            magnifiednodeid: "node-1".to_string(),
722            ..Default::default()
723        };
724        let json = mux_obj_to_json(&ls).unwrap();
725        let parsed: LayoutState = mux_obj_from_json(&json).unwrap();
726        assert_eq!(parsed.magnifiednodeid, "node-1");
727        assert!(parsed.rootnode.is_some());
728        assert_eq!(parsed.rootnode.as_ref().unwrap().id, "node-1");
729    }
730
731    /// Phase E.4.B Phase 2 acceptance gate: deserializing the JSON shape
732    /// the frontend produces today MUST yield the typed LayoutNode, and
733    /// reserializing MUST produce equivalent JSON. The shapes here cover:
734    ///   1. Single-leaf root (dnd / tear-off shape)
735    ///   2. A multi-pane shape (deep nesting + mixed group/leaf nodes) —
736    ///      the former first-launch four-pane layout, see below
737    ///   3. Edge: missing `children` array for leaves
738    #[test]
739    fn test_layout_node_serde_compat_with_frontend_shapes() {
740        // Shape 1 — single leaf (matches dnd.rs / service.rs writers).
741        let leaf_json = serde_json::json!({
742            "id": "node-uuid-1",
743            "data": { "blockId": "block-uuid-1" },
744            "flexDirection": "row",
745            "size": 1
746        });
747        let leaf: LayoutNode = serde_json::from_value(leaf_json).unwrap();
748        assert_eq!(leaf.id, "node-uuid-1");
749        assert_eq!(leaf.flex_direction, FlexDirection::Row);
750        assert_eq!(leaf.size, 1.0);
751        assert!(leaf.children.is_empty());
752        assert_eq!(leaf.data.as_ref().unwrap().block_id, "block-uuid-1");
753        // Round-trip preserves shape.
754        let reserialized = serde_json::to_value(&leaf).unwrap();
755        let reparsed: LayoutNode = serde_json::from_value(reserialized).unwrap();
756        assert_eq!(leaf, reparsed);
757
758        // Shape 2 — a nested row/column tree with a multi-child right
759        // column. This was the first-launch four-pane layout when written;
760        // the default is now three panes (wcore::default_three_pane_tree),
761        // but the fixture is kept as-is: what it exercises is serde
762        // round-tripping of a nested LayoutNode, not the current default.
763        let four_pane_json = serde_json::json!({
764            "id": "root-id",
765            "flexDirection": "row",
766            "size": 10,
767            "children": [
768                {
769                    "id": "agent-id",
770                    "flexDirection": "column",
771                    "size": 5,
772                    "data": { "blockId": "agent-block" }
773                },
774                {
775                    "id": "right-col-id",
776                    "flexDirection": "column",
777                    "size": 5,
778                    "children": [
779                        {
780                            "id": "swarm-id",
781                            "flexDirection": "row",
782                            "size": 4,
783                            "data": { "blockId": "swarm-block" }
784                        },
785                        {
786                            "id": "memory-id",
787                            "flexDirection": "row",
788                            "size": 4,
789                            "data": { "blockId": "memory-block" }
790                        },
791                        {
792                            "id": "sysinfo-id",
793                            "flexDirection": "row",
794                            "size": 2,
795                            "data": { "blockId": "sysinfo-block" }
796                        }
797                    ]
798                }
799            ]
800        });
801        let root: LayoutNode = serde_json::from_value(four_pane_json).unwrap();
802        assert_eq!(root.id, "root-id");
803        assert_eq!(root.children.len(), 2);
804        assert_eq!(root.children[0].id, "agent-id");
805        assert_eq!(root.children[0].flex_direction, FlexDirection::Column);
806        assert_eq!(root.children[0].data.as_ref().unwrap().block_id, "agent-block");
807        let right_col = &root.children[1];
808        assert_eq!(right_col.children.len(), 3);
809        assert_eq!(right_col.data, None); // group node, no leaf data
810        assert_eq!(right_col.children[0].data.as_ref().unwrap().block_id, "swarm-block");
811        assert_eq!(right_col.children[1].data.as_ref().unwrap().block_id, "memory-block");
812        assert_eq!(right_col.children[2].data.as_ref().unwrap().block_id, "sysinfo-block");
813        let reserialized = serde_json::to_value(&root).unwrap();
814        let reparsed: LayoutNode = serde_json::from_value(reserialized).unwrap();
815        assert_eq!(root, reparsed);
816
817        // Shape 3 — leaf without `children` array (real on-disk blobs may
818        // or may not have `"children": []`; deserializer must tolerate either).
819        let leaf_no_children = serde_json::json!({
820            "id": "loner",
821            "flexDirection": "column",
822            "size": 5,
823            "data": { "blockId": "b" }
824        });
825        let parsed: LayoutNode = serde_json::from_value(leaf_no_children).unwrap();
826        assert!(parsed.children.is_empty());
827    }
828
829    /// Phase E.4.B Phase 2 — deserializing a node WITHOUT `flexDirection`
830    /// falls back to `Row` default. Defensive against older blobs.
831    #[test]
832    fn test_layout_node_defaults_flex_direction_to_row() {
833        let no_dir = serde_json::json!({"id": "x", "size": 1});
834        let parsed: LayoutNode = serde_json::from_value(no_dir).unwrap();
835        assert_eq!(parsed.flex_direction, FlexDirection::Row);
836    }
837
838    /// Phase E.4.B Phase 2 (reagent P2 PR #688) — whole-number sizes
839    /// serialize as integer JSON literals, not float (`10` not `10.0`).
840    /// This preserves byte-equal compatibility with the prior
841    /// `serde_json::json!()`-produced JSON which used integer literals.
842    #[test]
843    fn test_layout_node_size_serializes_as_integer_when_whole() {
844        let whole = LayoutNode {
845            id: "x".into(),
846            flex_direction: FlexDirection::Row,
847            size: 10.0,
848            children: Vec::new(),
849            data: None,
850            ..Default::default()
851        };
852        let json = serde_json::to_string(&whole).unwrap();
853        assert!(json.contains("\"size\":10"), "whole sizes should serialize as integer; got {}", json);
854        assert!(!json.contains("\"size\":10.0"), "whole sizes should NOT include the decimal; got {}", json);
855
856        // Fractional sizes still serialize with the decimal.
857        let frac = LayoutNode {
858            id: "y".into(),
859            flex_direction: FlexDirection::Row,
860            size: 5.5,
861            children: Vec::new(),
862            data: None,
863            ..Default::default()
864        };
865        let json = serde_json::to_string(&frac).unwrap();
866        assert!(json.contains("\"size\":5.5"), "fractional sizes preserve the decimal; got {}", json);
867
868        // Round-trip works either way.
869        let reparsed: LayoutNode = serde_json::from_str(&serde_json::to_string(&whole).unwrap()).unwrap();
870        assert_eq!(reparsed.size, 10.0);
871    }
872
873    /// Phase E.4.B Phase 2 — `data: None` serializes ABSENT (not `"data": null`).
874    /// Frontend tolerates either, but absent matches the pre-typed JSON shape.
875    #[test]
876    fn test_layout_node_data_skipped_when_none() {
877        let group = LayoutNode {
878            id: "g".into(),
879            flex_direction: FlexDirection::Row,
880            size: 1.0,
881            children: Vec::new(),
882            data: None,
883            ..Default::default()
884        };
885        let json = serde_json::to_value(&group).unwrap();
886        assert!(json.get("data").is_none(), "data: None must skip-serialize");
887    }
888
889    /// Regression test: unknown fields on LayoutNode and LayoutNodeData round-
890    /// trip via the `extra: HashMap` catch-all (codex P1 PR #688 + #689).
891    /// Without this, fields the frontend writes that we don't yet model would
892    /// be silently dropped on deserialize→serialize cycles — a regression vs
893    /// the prior `serde_json::Value` storage.
894    #[test]
895    fn test_layout_node_preserves_unknown_fields() {
896        let json_with_extras = serde_json::json!({
897            "id": "n",
898            "flexDirection": "row",
899            "size": 1,
900            "backendLayoutVersion": 7,
901            "data": {
902                "blockId": "b",
903                "renderHint": "compact"
904            }
905        });
906        let node: LayoutNode = serde_json::from_value(json_with_extras).unwrap();
907        assert_eq!(node.extra.get("backendLayoutVersion"), Some(&serde_json::json!(7)));
908        assert_eq!(
909            node.data.as_ref().unwrap().extra.get("renderHint"),
910            Some(&serde_json::json!("compact"))
911        );
912        // Round-trip preserves both.
913        let reserialized = serde_json::to_value(&node).unwrap();
914        assert_eq!(reserialized.get("backendLayoutVersion"), Some(&serde_json::json!(7)));
915        assert_eq!(
916            reserialized.get("data").and_then(|d| d.get("renderHint")),
917            Some(&serde_json::json!("compact"))
918        );
919        // Empty extra → no stray "extra" key on output.
920        let no_extras = LayoutNode {
921            id: "x".into(),
922            flex_direction: FlexDirection::Row,
923            size: 1.0,
924            ..Default::default()
925        };
926        let json = serde_json::to_value(&no_extras).unwrap();
927        assert!(json.get("extra").is_none());
928    }
929
930    /// Regression test (codex P1 PR #689): missing blockId in JSON must fail
931    /// deserialization rather than silently default to "". An empty
932    /// block_id would cause incorrect matching wherever code resolves a
933    /// layout node by block id (e.g. the reducer's
934    /// `find_node_id_by_block`/`LayoutDeleteNodeByBlock`).
935    #[test]
936    fn test_layout_node_data_missing_block_id_fails_deserialization() {
937        let missing_id = serde_json::json!({ "renderHint": "compact" });
938        let result = serde_json::from_value::<LayoutNodeData>(missing_id);
939        assert!(result.is_err(), "missing blockId must fail deserialization");
940    }
941
942    #[test]
943    fn test_block_roundtrip() {
944        let block = Block {
945            oid: "blk-oid".to_string(),
946            version: 5,
947            parentoref: "tab:parent-id".to_string(),
948            meta: {
949                let mut m = MetaMapType::new();
950                m.insert("view".to_string(), serde_json::json!("term"));
951                m
952            },
953            ..Default::default()
954        };
955        let json = mux_obj_to_json(&block).unwrap();
956        let parsed: Block = mux_obj_from_json(&json).unwrap();
957        assert_eq!(parsed.parentoref, "tab:parent-id");
958        assert_eq!(
959            parsed.meta.get("view").and_then(|v| v.as_str()),
960            Some("term")
961        );
962    }
963
964    #[test]
965    fn test_wire_compat_go_json_client() {
966        // Hardcoded JSON from Go's encoding to verify wire compatibility
967        let go_json = r#"{"oid":"abc-123","otype":"client","version":2,"windowids":["w1"],"meta":{"view":"term"},"tosagreed":1700000000000}"#;
968        let client: Client = serde_json::from_str(go_json).unwrap();
969        assert_eq!(client.oid, "abc-123");
970        assert_eq!(client.version, 2);
971        assert_eq!(client.windowids, vec!["w1"]);
972        assert_eq!(client.tosagreed, 1700000000000);
973    }
974
975    #[test]
976    fn test_wire_compat_go_json_block() {
977        let go_json = r#"{"oid":"blk-1","otype":"block","version":3,"parentoref":"tab:t1","meta":{"view":"term","cmd":"ls"}}"#;
978        let block: Block = serde_json::from_str(go_json).unwrap();
979        assert_eq!(block.oid, "blk-1");
980        assert_eq!(block.parentoref, "tab:t1");
981        assert_eq!(block.version, 3);
982    }
983
984    #[test]
985    fn test_wire_compat_go_json_tab() {
986        let go_json = r#"{"oid":"tab-1","otype":"tab","version":1,"name":"Shell","layoutstate":"ls-1","blockids":["b1","b2"],"meta":{}}"#;
987        let tab: Tab = serde_json::from_str(go_json).unwrap();
988        assert_eq!(tab.name, "Shell");
989        assert_eq!(tab.blockids, vec!["b1", "b2"]);
990    }
991
992    #[test]
993    fn test_mux_obj_to_json_includes_otype() {
994        let client = Client {
995            oid: "test".to_string(),
996            version: 1,
997            ..Default::default()
998        };
999        let json_bytes = mux_obj_to_json(&client).unwrap();
1000        let v: serde_json::Value = serde_json::from_slice(&json_bytes).unwrap();
1001        assert_eq!(v["otype"], "client");
1002    }
1003
1004    #[test]
1005    fn test_merge_meta_basic() {
1006        let mut base = MetaMapType::new();
1007        base.insert("view".into(), serde_json::json!("term"));
1008        base.insert("cmd".into(), serde_json::json!("ls"));
1009
1010        let mut update = MetaMapType::new();
1011        update.insert("cmd".into(), serde_json::json!("pwd"));
1012        update.insert("icon".into(), serde_json::json!("star"));
1013
1014        let result = merge_meta(&base, &update, true);
1015        assert_eq!(result["view"], "term");
1016        assert_eq!(result["cmd"], "pwd");
1017        assert_eq!(result["icon"], "star");
1018    }
1019
1020    #[test]
1021    fn test_merge_meta_null_deletes() {
1022        let mut base = MetaMapType::new();
1023        base.insert("view".into(), serde_json::json!("term"));
1024        base.insert("cmd".into(), serde_json::json!("ls"));
1025
1026        let mut update = MetaMapType::new();
1027        update.insert("cmd".into(), serde_json::Value::Null);
1028
1029        let result = merge_meta(&base, &update, true);
1030        assert_eq!(result.get("view").unwrap(), "term");
1031        assert!(!result.contains_key("cmd"));
1032    }
1033
1034    #[test]
1035    fn test_merge_meta_section_clear() {
1036        let mut base = MetaMapType::new();
1037        base.insert("frame".into(), serde_json::json!(true));
1038        base.insert("frame:title".into(), serde_json::json!("hello"));
1039        base.insert("frame:icon".into(), serde_json::json!("star"));
1040        base.insert("cmd".into(), serde_json::json!("ls"));
1041
1042        let mut update = MetaMapType::new();
1043        update.insert("frame:*".into(), serde_json::json!(true));
1044
1045        let result = merge_meta(&base, &update, true);
1046        assert!(!result.contains_key("frame"));
1047        assert!(!result.contains_key("frame:title"));
1048        assert!(!result.contains_key("frame:icon"));
1049        assert_eq!(result["cmd"], "ls"); // unrelated key preserved
1050    }
1051
1052    #[test]
1053    fn test_merge_meta_skip_display_when_not_special() {
1054        let base = MetaMapType::new();
1055        let mut update = MetaMapType::new();
1056        update.insert("display:name".into(), serde_json::json!("test"));
1057        update.insert("view".into(), serde_json::json!("term"));
1058
1059        let result = merge_meta(&base, &update, false);
1060        assert!(!result.contains_key("display:name"));
1061        assert_eq!(result["view"], "term");
1062    }
1063
1064    #[test]
1065    fn test_meta_get_string() {
1066        let mut meta = MetaMapType::new();
1067        meta.insert("view".into(), serde_json::json!("term"));
1068        assert_eq!(meta_get_string(&meta, "view", "default"), "term");
1069        assert_eq!(meta_get_string(&meta, "missing", "default"), "default");
1070    }
1071
1072    #[test]
1073    fn test_meta_get_bool() {
1074        let mut meta = MetaMapType::new();
1075        meta.insert("edit".into(), serde_json::json!(true));
1076        assert!(meta_get_bool(&meta, "edit", false));
1077        assert!(!meta_get_bool(&meta, "missing", false));
1078    }
1079}