Skip to main content

sim_lib_view_bridge/
lib.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
8#![forbid(unsafe_code)]
9#![deny(missing_docs)]
10
11pub mod cookbook;
12
13use sim_codec_bridge::{
14    BridgeBook, BridgePacket, BridgePatchPayload, BridgeReceiptPayload, BridgeReviewPayload,
15    BridgeScore, BridgeVotePayload, expr_to_packet, packet_to_expr,
16};
17use sim_kernel::{Cx, Error, Expr, Result, Symbol};
18use sim_lib_view::codec::reduce_for_caps;
19use sim_lib_view::{Draft, Operation, SurfaceCaps, SurfaceCodec, roundtrip_holds};
20use sim_value::access::field;
21use sim_value::build::{entry, list, map, text};
22
23pub use cookbook::packet_review_demo;
24
25/// Stable id for the BRIDGE packet review surface codec.
26pub const BRIDGE_PACKET_SURFACE_CODEC_ID: &str = "surface:bridge-packet";
27
28/// Reversible surface codec for BRIDGE packet review.
29#[derive(Clone, Copy, Debug, Default)]
30pub struct BridgePacketSurfaceCodec;
31
32impl BridgePacketSurfaceCodec {
33    /// Builds the codec.
34    pub fn new() -> Self {
35        Self
36    }
37}
38
39impl SurfaceCodec for BridgePacketSurfaceCodec {
40    fn encode(&self, _cx: &mut Cx, value: &Expr, caps: &SurfaceCaps) -> Result<Expr> {
41        let packet = expr_to_packet(value)?;
42        let scene = packet_scene(&packet, caps)?;
43        Ok(reduce_for_caps(&scene, caps))
44    }
45
46    fn decode(&self, _cx: &mut Cx, value: &Expr, intent: &Expr) -> Result<Draft> {
47        sim_lib_intent::validate_intent(intent)
48            .map_err(|error| Error::HostError(format!("invalid intent: {error}")))?;
49        let packet = expr_to_packet(value)?;
50        let path = path_segments(intent)?;
51        let target = required_field(intent, "value")?;
52        if path.is_empty() {
53            return Ok(Draft::clean(value.clone(), target.clone()));
54        }
55        if path.first().map(String::as_str) != Some("bridge-collab") {
56            return Err(Error::Eval(
57                "BRIDGE packet surface only decodes bridge-collab edits".to_owned(),
58            ));
59        }
60        let action = path.get(1).map(String::as_str).ok_or_else(|| {
61            Error::Eval("BRIDGE collaboration edit is missing an action".to_owned())
62        })?;
63        let part = collaboration_part(&packet, action, target)?;
64        Ok(Draft::clean(value.clone(), part))
65    }
66
67    fn commit(&self, _cx: &mut Cx, draft: &Draft) -> Result<Operation> {
68        Ok(Operation {
69            form: map(vec![
70                (
71                    "op",
72                    Expr::Symbol(Symbol::qualified("bridge", "surface-edit")),
73                ),
74                ("value", draft.proposed.clone()),
75            ]),
76        })
77    }
78}
79
80/// Renders `packet` through the BRIDGE packet surface.
81pub fn bridge_packet_view(cx: &mut Cx, packet: &BridgePacket, caps: &SurfaceCaps) -> Result<Expr> {
82    BridgePacketSurfaceCodec::new().encode(cx, &packet_to_expr(packet), caps)
83}
84
85/// Decodes one edit intent against `packet` into a typed BRIDGE part record.
86pub fn bridge_packet_edit(cx: &mut Cx, packet: &BridgePacket, intent: &Expr) -> Result<Expr> {
87    let value = packet_to_expr(packet);
88    let codec = BridgePacketSurfaceCodec::new();
89    debug_assert!(roundtrip_holds(cx, &codec, &value).unwrap_or(false));
90    let draft = codec.decode(cx, &value, intent)?;
91    if !draft.committable {
92        return Err(Error::Eval(
93            "BRIDGE packet edit produced a rejected draft".to_owned(),
94        ));
95    }
96    Ok(draft.proposed)
97}
98
99fn packet_scene(packet: &BridgePacket, caps: &SurfaceCaps) -> Result<Expr> {
100    let profiles = BridgeBook::standard()
101        .profiles
102        .matching_profiles(packet)
103        .into_iter()
104        .map(|profile| profile.as_qualified_str())
105        .collect::<Vec<_>>()
106        .join(", ");
107    let cid = packet.header.cid.as_deref().unwrap_or("unstamped");
108    let mut children = vec![
109        sim_lib_scene::badge("surface", BRIDGE_PACKET_SURFACE_CODEC_ID),
110        sim_lib_scene::build::text_node(format!("cid {cid}")),
111        sim_lib_scene::build::text_node(format!(
112            "move {} from {}",
113            packet.header.move_kind.as_qualified_str(),
114            packet.header.from
115        )),
116        sim_lib_scene::build::text_node(format!(
117            "profiles {}",
118            if profiles.is_empty() {
119                "none".to_owned()
120            } else {
121                profiles
122            }
123        )),
124        sim_lib_scene::build::text_node(format!("surface {}", caps.preset_name())),
125    ];
126    children.extend(packet.body.iter().map(|part| {
127        sim_lib_scene::box_(
128            "part",
129            vec![
130                sim_lib_scene::badge("kind", &part.kind.as_qualified_str()),
131                sim_lib_scene::build::text_node(format!("id {}", part.id.as_qualified_str())),
132                sim_lib_scene::build::text_node(payload_summary(&part.payload)),
133            ],
134        )
135    }));
136    let scene = sim_lib_scene::stack("column", children);
137    sim_lib_scene::validate_scene(&scene)
138        .map_err(|error| Error::HostError(format!("invalid BRIDGE packet scene: {error}")))?;
139    Ok(scene)
140}
141
142fn collaboration_part(packet: &BridgePacket, action: &str, value: &Expr) -> Result<Expr> {
143    match action {
144        "patch" => {
145            let patch = BridgePatchPayload::new(
146                packet_cid(packet)?,
147                required_string(value, "target")?,
148                required_field(value, "replacement")?.clone(),
149            );
150            Ok(part_expr("P1", "Patch", patch.to_expr()))
151        }
152        "review" => {
153            let review = BridgeReviewPayload::new(
154                required_string(value, "target")?,
155                required_string(value, "body")?,
156            );
157            Ok(part_expr("R1", "Review", review.to_expr()))
158        }
159        "vote" => {
160            let vote = BridgeVotePayload::new(required_string(value, "target")?, scores(value)?);
161            Ok(part_expr("V1", "Vote", vote.to_expr()))
162        }
163        "receipt" => {
164            let receipt =
165                BridgeReceiptPayload::new(required_symbol(value, "status")?.clone(), 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(BridgeScore::from_expr)
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 refs(value: &Expr) -> Result<Vec<String>> {
196    required_vector(value, "refs")?
197        .iter()
198        .map(|item| match item {
199            Expr::String(value) => Ok(value.clone()),
200            _ => Err(Error::TypeMismatch {
201                expected: "string",
202                found: "non-string",
203            }),
204        })
205        .collect()
206}
207
208fn packet_cid(packet: &BridgePacket) -> Result<String> {
209    packet.header.cid.clone().ok_or_else(|| {
210        Error::Eval("BRIDGE packet surface edits require a stamped packet".to_owned())
211    })
212}
213
214fn path_segments(intent: &Expr) -> Result<Vec<String>> {
215    required_vector(intent, "path")?
216        .iter()
217        .map(|segment| match segment {
218            Expr::String(value) => Ok(value.clone()),
219            Expr::Symbol(symbol) => Ok(symbol.name.to_string()),
220            _ => Err(Error::Eval(
221                "BRIDGE packet edit path segments must be strings or symbols".to_owned(),
222            )),
223        })
224        .collect()
225}
226
227fn required_field<'a>(expr: &'a Expr, name: &str) -> Result<&'a Expr> {
228    field(expr, name).ok_or_else(|| Error::Eval(format!("missing field {name}")))
229}
230
231fn required_string<'a>(expr: &'a Expr, name: &str) -> Result<&'a str> {
232    match required_field(expr, name)? {
233        Expr::String(value) => Ok(value),
234        _ => Err(Error::TypeMismatch {
235            expected: "string",
236            found: "non-string",
237        }),
238    }
239}
240
241fn required_symbol<'a>(expr: &'a Expr, name: &str) -> Result<&'a Symbol> {
242    match required_field(expr, name)? {
243        Expr::Symbol(value) => Ok(value),
244        _ => Err(Error::TypeMismatch {
245            expected: "symbol",
246            found: "non-symbol",
247        }),
248    }
249}
250
251fn required_vector<'a>(expr: &'a Expr, name: &str) -> Result<&'a [Expr]> {
252    match required_field(expr, name)? {
253        Expr::List(items) | Expr::Vector(items) => Ok(items),
254        _ => Err(Error::Eval(format!("field {name} must be a list"))),
255    }
256}
257
258fn payload_summary(payload: &Expr) -> String {
259    let rendered = format!("{payload:?}");
260    if rendered.len() <= 96 {
261        rendered
262    } else {
263        format!("{}...", &rendered[..96])
264    }
265}
266
267/// Builds a patch edit intent for the packet review surface.
268pub fn patch_edit_intent(target: &str, replacement: Expr, origin: sim_lib_intent::Origin) -> Expr {
269    sim_lib_intent::intent(
270        "edit-field",
271        origin,
272        vec![
273            ("target", Expr::Symbol(Symbol::new("bridge-packet"))),
274            ("path", list(vec![text("bridge-collab"), text("patch")])),
275            (
276                "value",
277                map(vec![("target", text(target)), ("replacement", replacement)]),
278            ),
279        ],
280    )
281}
282
283/// Builds a review edit intent for the packet review surface.
284pub fn review_edit_intent(target: &str, body: &str, origin: sim_lib_intent::Origin) -> Expr {
285    sim_lib_intent::intent(
286        "edit-field",
287        origin,
288        vec![
289            ("target", Expr::Symbol(Symbol::new("bridge-packet"))),
290            ("path", list(vec![text("bridge-collab"), text("review")])),
291            (
292                "value",
293                map(vec![("target", text(target)), ("body", text(body))]),
294            ),
295        ],
296    )
297}
298
299/// Builds a vote edit intent for the packet review surface.
300pub fn vote_edit_intent(
301    target: &str,
302    scores: Vec<BridgeScore>,
303    origin: sim_lib_intent::Origin,
304) -> Expr {
305    sim_lib_intent::intent(
306        "edit-field",
307        origin,
308        vec![
309            ("target", Expr::Symbol(Symbol::new("bridge-packet"))),
310            ("path", list(vec![text("bridge-collab"), text("vote")])),
311            (
312                "value",
313                map(vec![
314                    ("target", text(target)),
315                    (
316                        "scores",
317                        Expr::Vector(scores.iter().map(BridgeScore::to_expr).collect()),
318                    ),
319                ]),
320            ),
321        ],
322    )
323}
324
325/// Builds a receipt edit intent for the packet review surface.
326pub fn receipt_edit_intent(
327    status: Symbol,
328    refs: Vec<String>,
329    origin: sim_lib_intent::Origin,
330) -> Expr {
331    sim_lib_intent::intent(
332        "edit-field",
333        origin,
334        vec![
335            ("target", Expr::Symbol(Symbol::new("bridge-packet"))),
336            ("path", list(vec![text("bridge-collab"), text("receipt")])),
337            (
338                "value",
339                map(vec![
340                    ("status", Expr::Symbol(status)),
341                    ("refs", list(refs.into_iter().map(text).collect::<Vec<_>>())),
342                ]),
343            ),
344        ],
345    )
346}
347
348#[cfg(test)]
349mod tests;