1use std::collections::BTreeMap;
4
5use sim_kernel::{Cx, Symbol, Value};
6
7use crate::{Doc, OfficeError};
8
9pub const TAG_LENS: &str = "lens";
11pub const TAG_TARGET: &str = "target";
13pub const TAG_BACKEND: &str = "backend";
15pub const TAG_STATEMENT_KIND: &str = "statement-kind";
17pub const TAG_FIDELITY: &str = "fidelity";
19
20const LENS_SOURCE: &str = "source";
21const LENS_FORMATTED: &str = "formatted";
22const TARGET_DECK: &str = "deck";
23const TARGET_SCREEN: &str = "screen";
24const FIDELITY_SUMMARY: &str = "summary";
25const FIDELITY_STANDARD: &str = "standard";
26const FIDELITY_FULL: &str = "full";
27
28#[derive(Clone, Debug, Default, PartialEq, Eq)]
33pub struct ProjectionCaps {
34 pub tags: BTreeMap<String, String>,
36}
37
38impl ProjectionCaps {
39 #[must_use]
41 pub fn new() -> Self {
42 Self::default()
43 }
44
45 #[must_use]
47 pub fn with_tag(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
48 self.tags.insert(key.into(), value.into());
49 self
50 }
51
52 #[must_use]
54 pub fn get(&self, key: &str) -> Option<&str> {
55 self.tags.get(key).map(String::as_str)
56 }
57
58 #[must_use]
60 pub fn lens(self, lens: &str) -> Self {
61 self.with_tag(TAG_LENS, lens)
62 }
63
64 #[must_use]
66 pub fn target(self, target: &str) -> Self {
67 self.with_tag(TAG_TARGET, target)
68 }
69
70 #[must_use]
72 pub fn backend(self, backend: &str) -> Self {
73 self.with_tag(TAG_BACKEND, backend)
74 }
75
76 #[must_use]
78 pub fn statement_kind(self, statement_kind: &str) -> Self {
79 self.with_tag(TAG_STATEMENT_KIND, statement_kind)
80 }
81
82 #[must_use]
84 pub fn fidelity(self, fidelity: &str) -> Self {
85 self.with_tag(TAG_FIDELITY, fidelity)
86 }
87}
88
89pub type SurfaceCaps = ProjectionCaps;
91
92#[derive(Clone, Debug, PartialEq)]
94pub struct ProjectionRequest<'a> {
95 pub doc: &'a Doc,
97 pub caps: &'a ProjectionCaps,
99}
100
101impl<'a> ProjectionRequest<'a> {
102 #[must_use]
104 pub fn new(doc: &'a Doc, caps: &'a ProjectionCaps) -> Self {
105 Self { doc, caps }
106 }
107}
108
109#[derive(Clone, Copy, Debug, PartialEq, Eq)]
110struct ProjectionBranch {
111 name: &'static str,
112 required_tags: &'static [(&'static str, &'static str)],
113}
114
115impl ProjectionBranch {
116 fn score(self, caps: &ProjectionCaps) -> Option<usize> {
117 let mut score = 0;
118 for (key, value) in self.required_tags {
119 if caps.get(key)? != *value {
120 return None;
121 }
122 score += 10;
123 }
124 Some(score)
125 }
126}
127
128const SCREEN_BRANCH: ProjectionBranch = ProjectionBranch {
129 name: "screen-pane",
130 required_tags: &[(TAG_TARGET, TARGET_SCREEN)],
131};
132const DECK_BRANCH: ProjectionBranch = ProjectionBranch {
133 name: "deck-export",
134 required_tags: &[(TAG_TARGET, TARGET_DECK)],
135};
136const STATEMENT_BRANCH: ProjectionBranch = ProjectionBranch {
137 name: "statement-table",
138 required_tags: &[(TAG_STATEMENT_KIND, "statement")],
139};
140const GENERIC_BRANCH: ProjectionBranch = ProjectionBranch {
141 name: "generic-doc",
142 required_tags: &[],
143};
144
145const BRANCHES: &[ProjectionBranch] =
146 &[SCREEN_BRANCH, DECK_BRANCH, STATEMENT_BRANCH, GENERIC_BRANCH];
147
148pub fn project(cx: &mut Cx, req: &ProjectionRequest<'_>) -> Result<Value, OfficeError> {
150 let branch = rank_branch(req.caps);
151 let fidelity = req.caps.get(TAG_FIDELITY).unwrap_or(FIDELITY_STANDARD);
152 let lens = req.caps.get(TAG_LENS).unwrap_or(LENS_FORMATTED);
153 let mut entries = vec![
154 symbol_value(cx, "kind", "office/projection")?,
155 string_value(cx, "branch", branch.name)?,
156 string_value(cx, "doc-kind", req.doc.kind.as_str())?,
157 string_value(cx, "doc-id", req.doc.id.as_str())?,
158 string_value(cx, TAG_LENS, lens)?,
159 string_value(cx, TAG_FIDELITY, fidelity)?,
160 ];
161
162 if fidelity != FIDELITY_SUMMARY {
163 entries.push(string_value(
164 cx,
165 TAG_TARGET,
166 req.caps.get(TAG_TARGET).unwrap_or("generic"),
167 )?);
168 if let Some(backend) = req.caps.get(TAG_BACKEND) {
169 entries.push(string_value(cx, TAG_BACKEND, backend)?);
170 }
171 if let Some(statement_kind) = req.caps.get(TAG_STATEMENT_KIND) {
172 entries.push(string_value(cx, TAG_STATEMENT_KIND, statement_kind)?);
173 }
174 entries.push(body_value(cx, lens, &req.doc.body)?);
175 }
176
177 if fidelity == FIDELITY_FULL {
178 entries.push(tags_value(cx, req.caps)?);
179 entries.push(origin_value(cx, req.doc)?);
180 }
181
182 cx.factory().table(entries).map_err(OfficeError::from)
183}
184
185fn rank_branch(caps: &ProjectionCaps) -> ProjectionBranch {
186 BRANCHES
187 .iter()
188 .enumerate()
189 .filter_map(|(index, branch)| branch.score(caps).map(|score| (*branch, score, index)))
190 .max_by(|left, right| left.1.cmp(&right.1).then_with(|| right.2.cmp(&left.2)))
191 .map(|(branch, _, _)| branch)
192 .unwrap_or(GENERIC_BRANCH)
193}
194
195fn symbol_value(cx: &mut Cx, key: &str, value: &str) -> Result<(Symbol, Value), OfficeError> {
196 Ok((
197 Symbol::new(key),
198 cx.factory()
199 .symbol(Symbol::new(value.to_owned()))
200 .map_err(OfficeError::from)?,
201 ))
202}
203
204fn string_value(cx: &mut Cx, key: &str, value: &str) -> Result<(Symbol, Value), OfficeError> {
205 Ok((
206 Symbol::new(key),
207 cx.factory()
208 .string(value.to_owned())
209 .map_err(OfficeError::from)?,
210 ))
211}
212
213fn body_value(cx: &mut Cx, lens: &str, body: &Value) -> Result<(Symbol, Value), OfficeError> {
214 let value = if lens == LENS_SOURCE {
215 body.clone()
216 } else {
217 let display = body.object().display(cx).map_err(OfficeError::from)?;
218 cx.factory().string(display).map_err(OfficeError::from)?
219 };
220 Ok((Symbol::new("body"), value))
221}
222
223fn tags_value(cx: &mut Cx, caps: &ProjectionCaps) -> Result<(Symbol, Value), OfficeError> {
224 let mut pairs = Vec::with_capacity(caps.tags.len());
225 for (key, value) in &caps.tags {
226 pairs.push((
227 Symbol::new(key.clone()),
228 cx.factory()
229 .string(value.clone())
230 .map_err(OfficeError::from)?,
231 ));
232 }
233 Ok((
234 Symbol::new("caps"),
235 cx.factory().table(pairs).map_err(OfficeError::from)?,
236 ))
237}
238
239fn origin_value(cx: &mut Cx, doc: &Doc) -> Result<(Symbol, Value), OfficeError> {
240 let mut refs = Vec::with_capacity(doc.origin.len());
241 for external in &doc.origin {
242 let mut fields = vec![
243 string_value(cx, TAG_BACKEND, &external.backend)?,
244 string_value(cx, "external-id", &external.external_id)?,
245 ];
246 if let Some(version) = &external.version {
247 fields.push(string_value(cx, "version", version)?);
248 }
249 if let Some(web_url) = &external.web_url {
250 fields.push(string_value(cx, "web-url", web_url)?);
251 }
252 refs.push(cx.factory().table(fields).map_err(OfficeError::from)?);
253 }
254 Ok((
255 Symbol::new("origin"),
256 cx.factory().list(refs).map_err(OfficeError::from)?,
257 ))
258}
259
260#[cfg(test)]
261mod tests {
262 use sim_kernel::{Expr, testing::bare_cx as cx};
263
264 use super::*;
265 use crate::{DocId, DocKind};
266
267 fn doc(cx: &mut Cx) -> Doc {
268 Doc::new(
269 DocKind::new("report"),
270 DocId::new("doc-1"),
271 cx.factory().string("body text".to_owned()).unwrap(),
272 vec![],
273 )
274 }
275
276 fn projected_expr(cx: &mut Cx, doc: &Doc, caps: &ProjectionCaps) -> Expr {
277 project(cx, &ProjectionRequest::new(doc, caps))
278 .unwrap()
279 .object()
280 .as_expr(cx)
281 .unwrap()
282 }
283
284 fn map_len(expr: &Expr) -> usize {
285 let Expr::Map(entries) = expr else {
286 panic!("projection must be a map");
287 };
288 entries.len()
289 }
290
291 fn string_field(expr: &Expr, name: &str) -> String {
292 let Expr::Map(entries) = expr else {
293 panic!("projection must be a map");
294 };
295 let value = entries
296 .iter()
297 .find_map(|(key, value)| match key {
298 Expr::Symbol(symbol) if symbol.name.as_ref() == name => Some(value),
299 _ => None,
300 })
301 .unwrap_or_else(|| panic!("missing field {name}"));
302 match value {
303 Expr::String(text) => text.clone(),
304 Expr::Symbol(symbol) => symbol.to_string(),
305 other => panic!("field {name} is not a string-like value: {other:?}"),
306 }
307 }
308
309 #[test]
310 fn summary_projection_has_fewer_fields_than_full() {
311 let mut cx = cx();
312 let doc = doc(&mut cx);
313 let summary = ProjectionCaps::new()
314 .target(TARGET_SCREEN)
315 .fidelity(FIDELITY_SUMMARY);
316 let full = ProjectionCaps::new()
317 .target(TARGET_SCREEN)
318 .backend("codec/ooxml")
319 .statement_kind("statement")
320 .fidelity(FIDELITY_FULL);
321
322 let summary_expr = projected_expr(&mut cx, &doc, &summary);
323 let full_expr = projected_expr(&mut cx, &doc, &full);
324
325 assert!(map_len(&summary_expr) < map_len(&full_expr));
326 assert_eq!(string_field(&summary_expr, "branch"), "screen-pane");
327 }
328
329 #[test]
330 fn deck_and_screen_targets_select_different_branches() {
331 let mut cx = cx();
332 let doc = doc(&mut cx);
333 let screen = ProjectionCaps::new().target(TARGET_SCREEN);
334 let deck = ProjectionCaps::new().target(TARGET_DECK);
335
336 let screen_expr = projected_expr(&mut cx, &doc, &screen);
337 let deck_expr = projected_expr(&mut cx, &doc, &deck);
338
339 assert_eq!(string_field(&screen_expr, "branch"), "screen-pane");
340 assert_eq!(string_field(&deck_expr, "branch"), "deck-export");
341 }
342
343 #[test]
344 fn unknown_caps_fall_back_deterministically() {
345 let mut cx = cx();
346 let doc = doc(&mut cx);
347 let caps = ProjectionCaps::new()
348 .target("unknown")
349 .with_tag("unknown-tag", "value");
350
351 let first = projected_expr(&mut cx, &doc, &caps);
352 let second = projected_expr(&mut cx, &doc, &caps);
353
354 assert!(first.canonical_eq(&second));
355 assert_eq!(string_field(&first, "branch"), "generic-doc");
356 }
357
358 #[test]
359 fn source_and_formatted_lenses_rank_without_closed_enum() {
360 let mut cx = cx();
361 let doc = doc(&mut cx);
362 let source = ProjectionCaps::new().lens(LENS_SOURCE);
363 let formatted = ProjectionCaps::new().lens(LENS_FORMATTED);
364
365 let source_expr = projected_expr(&mut cx, &doc, &source);
366 let formatted_expr = projected_expr(&mut cx, &doc, &formatted);
367
368 assert_eq!(string_field(&source_expr, TAG_LENS), LENS_SOURCE);
369 assert_eq!(string_field(&formatted_expr, TAG_LENS), LENS_FORMATTED);
370 assert_ne!(source_expr, formatted_expr);
371 }
372}