agentmux_srv\backend/
obj.rs

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