Skip to main content

sim_lib_view_bridge/
surface.rs

1//! Reversible BRIDGE packet review surface for SIM Web.
2//!
3//! The surface renders one `BridgePacket` as Scene data and decodes ordinary
4//! `intent/edit-field` values into typed BRIDGE collaboration parts. Human and
5//! agent operators edit the same packet expression: a patch, review, vote, or
6//! receipt is a BRIDGE part record, not a separate browser-side protocol.
7
8mod controls;
9
10use sim_codec_bridge::{
11    BridgeBook, BridgePacket, BridgePatchPayload, BridgeReceiptPayload, BridgeReviewPayload,
12    BridgeScore, BridgeVotePayload, expr_to_packet, packet_to_expr, validate_collab_payload,
13};
14use sim_kernel::{Cx, Error, Expr, Result, Symbol};
15use sim_lib_view::codec::reduce_for_caps;
16use sim_lib_view::{Draft, Operation, SurfaceCaps, SurfaceCodec};
17use sim_value::access::field;
18use sim_value::build::{entry, list, map, text};
19
20/// Stable id for the BRIDGE packet review surface codec.
21pub const BRIDGE_PACKET_SURFACE_CODEC_ID: &str = "surface:bridge-packet";
22
23/// Reversible surface codec for BRIDGE packet review.
24#[derive(Clone, Copy, Debug, Default)]
25pub struct BridgePacketSurfaceCodec;
26
27impl BridgePacketSurfaceCodec {
28    /// Builds the codec.
29    pub fn new() -> Self {
30        Self
31    }
32}
33
34impl SurfaceCodec for BridgePacketSurfaceCodec {
35    fn encode(&self, _cx: &mut Cx, value: &Expr, caps: &SurfaceCaps) -> Result<Expr> {
36        let packet = expr_to_packet(value)?;
37        if let Some(scene) = crate::glasses_review::glasses_warrant_scene_for_caps(&packet, caps)? {
38            return Ok(scene);
39        }
40        let scene = packet_scene(&packet, caps)?;
41        Ok(reduce_for_caps(&scene, caps))
42    }
43
44    fn decode(&self, _cx: &mut Cx, value: &Expr, intent: &Expr) -> Result<Draft> {
45        sim_lib_intent::validate_intent(intent)
46            .map_err(|error| Error::HostError(format!("invalid intent: {error}")))?;
47        require_intent_kind(intent, "edit-field")?;
48        let packet = expr_to_packet(value)?;
49        require_packet_target(&packet, intent)?;
50        let path = path_segments(intent)?;
51        let action = match path.as_slice() {
52            [lane, action] if lane == "bridge-collab" => action.as_str(),
53            _ => {
54                return Err(Error::Eval(
55                    "BRIDGE edit must target bridge-collab action".to_owned(),
56                ));
57            }
58        };
59        let part = collaboration_part(&packet, action, required_field(intent, "value")?)?;
60        validate_bridge_part(&part)?;
61        Ok(Draft::clean(value.clone(), part))
62    }
63
64    fn commit(&self, _cx: &mut Cx, draft: &Draft) -> Result<Operation> {
65        Ok(Operation::new(map(vec![
66            (
67                "op",
68                Expr::Symbol(Symbol::qualified("bridge", "surface-edit")),
69            ),
70            ("value", draft.proposed.clone()),
71        ])))
72    }
73}
74
75/// Renders `packet` through the BRIDGE packet surface.
76pub fn bridge_packet_view(cx: &mut Cx, packet: &BridgePacket, caps: &SurfaceCaps) -> Result<Expr> {
77    BridgePacketSurfaceCodec::new().encode(cx, &packet_to_expr(packet), caps)
78}
79
80/// Decodes one edit intent against `packet` into a typed BRIDGE part record.
81pub fn bridge_packet_edit(cx: &mut Cx, packet: &BridgePacket, intent: &Expr) -> Result<Expr> {
82    let value = packet_to_expr(packet);
83    let codec = BridgePacketSurfaceCodec::new();
84    let draft = codec.decode(cx, &value, intent)?;
85    if !draft.committable {
86        return Err(Error::Eval(
87            "BRIDGE packet edit produced a rejected draft".to_owned(),
88        ));
89    }
90    Ok(draft.proposed)
91}
92
93fn packet_scene(packet: &BridgePacket, caps: &SurfaceCaps) -> Result<Expr> {
94    let profiles = BridgeBook::standard()
95        .profiles
96        .matching_profiles(packet)
97        .into_iter()
98        .map(|profile| profile.as_qualified_str())
99        .collect::<Vec<_>>()
100        .join(", ");
101    let cid = packet.header.cid.as_deref().unwrap_or("unstamped");
102    let mut children = vec![
103        sim_lib_scene::badge("surface", BRIDGE_PACKET_SURFACE_CODEC_ID),
104        sim_lib_scene::build::text_node(format!("cid {cid}")),
105        sim_lib_scene::build::text_node(format!(
106            "move {} from {}",
107            packet.header.move_kind.as_qualified_str(),
108            packet.header.from
109        )),
110        sim_lib_scene::build::text_node(format!(
111            "profiles {}",
112            if profiles.is_empty() {
113                "none".to_owned()
114            } else {
115                profiles
116            }
117        )),
118        sim_lib_scene::build::text_node(format!("surface {}", caps.preset_name())),
119    ];
120    children.extend(packet.body.iter().map(|part| {
121        sim_lib_scene::box_(
122            "part",
123            vec![
124                sim_lib_scene::badge("kind", &part.kind.as_qualified_str()),
125                sim_lib_scene::build::text_node(format!("id {}", part.id.as_qualified_str())),
126                sim_lib_scene::build::text_node(payload_summary(&part.payload)),
127            ],
128        )
129    }));
130    children.push(controls::collaboration_controls(packet));
131    let scene = sim_lib_scene::stack("column", children);
132    sim_lib_scene::validate_scene(&scene)
133        .map_err(|error| Error::HostError(format!("invalid BRIDGE packet scene: {error}")))?;
134    Ok(scene)
135}
136
137fn collaboration_part(packet: &BridgePacket, action: &str, value: &Expr) -> Result<Expr> {
138    match action {
139        "patch" => {
140            reject_unknown_fields(value, &["target", "replacement"], "bridge/Patch edit")?;
141            let patch = BridgePatchPayload::new(
142                packet_cid(packet)?,
143                required_non_empty_string(value, "target")?,
144                required_field(value, "replacement")?.clone(),
145            );
146            Ok(part_expr("P1", "Patch", patch.to_expr()))
147        }
148        "review" => {
149            reject_unknown_fields(value, &["target", "body"], "bridge/Review edit")?;
150            let review = BridgeReviewPayload::new(
151                required_non_empty_string(value, "target")?,
152                required_non_empty_string(value, "body")?,
153            );
154            Ok(part_expr("R1", "Review", review.to_expr()))
155        }
156        "vote" => {
157            reject_unknown_fields(value, &["target", "scores"], "bridge/Vote edit")?;
158            let vote =
159                BridgeVotePayload::new(required_non_empty_string(value, "target")?, scores(value)?);
160            Ok(part_expr("V1", "Vote", vote.to_expr()))
161        }
162        "receipt" => {
163            reject_unknown_fields(value, &["status", "refs"], "bridge/Receipt edit")?;
164            let receipt =
165                BridgeReceiptPayload::new(required_symbol_like(value, "status")?, refs(value)?);
166            Ok(part_expr("Rc1", "Receipt", receipt.to_expr()))
167        }
168        other => Err(Error::Eval(format!(
169            "unknown BRIDGE collaboration edit action {other}"
170        ))),
171    }
172}
173
174fn part_expr(id: &str, kind: &str, payload: Expr) -> Expr {
175    Expr::Map(vec![
176        entry("id", Expr::Symbol(Symbol::new(id))),
177        entry("kind", Expr::Symbol(Symbol::qualified("bridge", kind))),
178        entry("payload", payload),
179    ])
180}
181
182fn scores(value: &Expr) -> Result<Vec<BridgeScore>> {
183    let scores = required_vector(value, "scores")?
184        .iter()
185        .map(score)
186        .collect::<Result<Vec<_>>>()?;
187    if scores.is_empty() {
188        return Err(Error::Eval(
189            "BRIDGE packet vote edit requires at least one score".to_owned(),
190        ));
191    }
192    Ok(scores)
193}
194
195fn score(value: &Expr) -> Result<BridgeScore> {
196    if let Ok(score) = BridgeScore::from_expr(value) {
197        return Ok(score);
198    }
199    reject_unknown_fields(value, &["axis", "value", "reason"], "bridge/Score edit")?;
200    Ok(BridgeScore::new(
201        required_symbol_like(value, "axis")?,
202        required_i64_like(value, "value")?,
203        required_non_empty_string(value, "reason")?,
204    ))
205}
206
207fn refs(value: &Expr) -> Result<Vec<String>> {
208    required_vector(value, "refs")?
209        .iter()
210        .map(|item| match item {
211            Expr::String(value) => Ok(value.clone()),
212            _ => Err(Error::TypeMismatch {
213                expected: "string",
214                found: "non-string",
215            }),
216        })
217        .collect()
218}
219
220fn require_intent_kind(intent: &Expr, expected: &str) -> Result<()> {
221    let kind = sim_lib_intent::intent_kind_of(intent)
222        .ok_or_else(|| Error::Eval("intent is missing kind".to_owned()))?;
223    let expected_kind = sim_lib_intent::intent_kind(expected);
224    if kind != expected_kind {
225        return Err(Error::Eval(format!("expected intent/{expected}")));
226    }
227    Ok(())
228}
229
230fn require_packet_target(packet: &BridgePacket, intent: &Expr) -> Result<()> {
231    let target = required_field(intent, "target")?;
232    if target_matches(target, "bridge-packet")
233        || packet
234            .header
235            .cid
236            .as_deref()
237            .is_some_and(|cid| target_matches(target, cid))
238    {
239        return Ok(());
240    }
241    Err(Error::Eval(
242        "BRIDGE edit target must be bridge-packet or the packet cid".to_owned(),
243    ))
244}
245
246fn target_matches(target: &Expr, expected: &str) -> bool {
247    match target {
248        Expr::String(value) => value == expected,
249        Expr::Symbol(symbol) => symbol.as_qualified_str() == expected,
250        _ => false,
251    }
252}
253
254fn validate_bridge_part(part: &Expr) -> Result<()> {
255    let kind = required_symbol(part, "kind")?;
256    let payload = required_field(part, "payload")?;
257    validate_collab_payload(kind, payload)
258}
259
260fn packet_cid(packet: &BridgePacket) -> Result<String> {
261    packet.header.cid.clone().ok_or_else(|| {
262        Error::Eval("BRIDGE packet surface edits require a stamped packet".to_owned())
263    })
264}
265
266fn path_segments(intent: &Expr) -> Result<Vec<String>> {
267    required_vector(intent, "path")?
268        .iter()
269        .map(|segment| match segment {
270            Expr::String(value) => Ok(value.clone()),
271            Expr::Symbol(symbol) => Ok(symbol.name.to_string()),
272            _ => Err(Error::Eval(
273                "BRIDGE packet edit path segments must be strings or symbols".to_owned(),
274            )),
275        })
276        .collect()
277}
278
279fn required_field<'a>(expr: &'a Expr, name: &str) -> Result<&'a Expr> {
280    field(expr, name).ok_or_else(|| Error::Eval(format!("missing field {name}")))
281}
282
283fn required_string<'a>(expr: &'a Expr, name: &str) -> Result<&'a str> {
284    match required_field(expr, name)? {
285        Expr::String(value) => Ok(value),
286        _ => Err(Error::TypeMismatch {
287            expected: "string",
288            found: "non-string",
289        }),
290    }
291}
292
293fn required_non_empty_string<'a>(expr: &'a Expr, name: &str) -> Result<&'a str> {
294    let value = required_string(expr, name)?;
295    if value.trim().is_empty() {
296        return Err(Error::Eval(format!("field {name} must not be empty")));
297    }
298    Ok(value)
299}
300
301fn required_symbol<'a>(expr: &'a Expr, name: &str) -> Result<&'a Symbol> {
302    match required_field(expr, name)? {
303        Expr::Symbol(value) => Ok(value),
304        _ => Err(Error::TypeMismatch {
305            expected: "symbol",
306            found: "non-symbol",
307        }),
308    }
309}
310
311fn required_symbol_like(expr: &Expr, name: &str) -> Result<Symbol> {
312    match required_field(expr, name)? {
313        Expr::Symbol(value) => Ok(value.clone()),
314        Expr::String(value) if !value.trim().is_empty() => Ok(Symbol::new(value.clone())),
315        Expr::String(_) => Err(Error::Eval(format!("field {name} must not be empty"))),
316        _ => Err(Error::TypeMismatch {
317            expected: "symbol or string",
318            found: "non-symbol",
319        }),
320    }
321}
322
323fn required_i64_like(expr: &Expr, name: &str) -> Result<i64> {
324    match required_field(expr, name)? {
325        Expr::Number(number) => number
326            .canonical
327            .parse()
328            .map_err(|_| Error::Eval(format!("field {name} must be an i64 literal"))),
329        Expr::String(value) => value
330            .parse()
331            .map_err(|_| Error::Eval(format!("field {name} must be an i64 literal"))),
332        _ => Err(Error::TypeMismatch {
333            expected: "number or numeric string",
334            found: "non-number",
335        }),
336    }
337}
338
339fn required_vector<'a>(expr: &'a Expr, name: &str) -> Result<&'a [Expr]> {
340    match required_field(expr, name)? {
341        Expr::List(items) | Expr::Vector(items) => Ok(items),
342        _ => Err(Error::Eval(format!("field {name} must be a list"))),
343    }
344}
345
346fn reject_unknown_fields(expr: &Expr, allowed: &[&str], label: &str) -> Result<()> {
347    let Expr::Map(fields) = expr else {
348        return Err(Error::Eval(format!("{label} must be a map")));
349    };
350    for (key, _) in fields {
351        let Some(name) = field_name(key) else {
352            return Err(Error::Eval(format!("{label} field keys must be symbols")));
353        };
354        if !allowed.contains(&name.as_str()) {
355            return Err(Error::Eval(format!("unknown {label} field {name}")));
356        }
357    }
358    Ok(())
359}
360
361fn field_name(expr: &Expr) -> Option<String> {
362    match expr {
363        Expr::Symbol(symbol) => Some(symbol.name.to_string()),
364        Expr::String(value) => Some(value.clone()),
365        _ => None,
366    }
367}
368
369fn payload_summary(payload: &Expr) -> String {
370    let rendered = format!("{payload:?}");
371    if rendered.len() <= 96 {
372        rendered
373    } else {
374        format!("{}...", &rendered[..96])
375    }
376}
377
378/// Builds a patch edit intent for the packet review surface.
379pub fn patch_edit_intent(target: &str, replacement: Expr, origin: sim_lib_intent::Origin) -> Expr {
380    sim_lib_intent::intent(
381        "edit-field",
382        origin,
383        vec![
384            ("target", Expr::Symbol(Symbol::new("bridge-packet"))),
385            ("path", list(vec![text("bridge-collab"), text("patch")])),
386            (
387                "value",
388                map(vec![("target", text(target)), ("replacement", replacement)]),
389            ),
390        ],
391    )
392}
393
394/// Builds a review edit intent for the packet review surface.
395pub fn review_edit_intent(target: &str, body: &str, origin: sim_lib_intent::Origin) -> Expr {
396    sim_lib_intent::intent(
397        "edit-field",
398        origin,
399        vec![
400            ("target", Expr::Symbol(Symbol::new("bridge-packet"))),
401            ("path", list(vec![text("bridge-collab"), text("review")])),
402            (
403                "value",
404                map(vec![("target", text(target)), ("body", text(body))]),
405            ),
406        ],
407    )
408}
409
410/// Builds a vote edit intent for the packet review surface.
411pub fn vote_edit_intent(
412    target: &str,
413    scores: Vec<BridgeScore>,
414    origin: sim_lib_intent::Origin,
415) -> Expr {
416    sim_lib_intent::intent(
417        "edit-field",
418        origin,
419        vec![
420            ("target", Expr::Symbol(Symbol::new("bridge-packet"))),
421            ("path", list(vec![text("bridge-collab"), text("vote")])),
422            (
423                "value",
424                map(vec![
425                    ("target", text(target)),
426                    (
427                        "scores",
428                        Expr::Vector(scores.iter().map(BridgeScore::to_expr).collect()),
429                    ),
430                ]),
431            ),
432        ],
433    )
434}
435
436/// Builds a receipt edit intent for the packet review surface.
437pub fn receipt_edit_intent(
438    status: Symbol,
439    refs: Vec<String>,
440    origin: sim_lib_intent::Origin,
441) -> Expr {
442    sim_lib_intent::intent(
443        "edit-field",
444        origin,
445        vec![
446            ("target", Expr::Symbol(Symbol::new("bridge-packet"))),
447            ("path", list(vec![text("bridge-collab"), text("receipt")])),
448            (
449                "value",
450                map(vec![
451                    ("status", Expr::Symbol(status)),
452                    ("refs", list(refs.into_iter().map(text).collect::<Vec<_>>())),
453                ]),
454            ),
455        ],
456    )
457}