Skip to main content

tauri_plugin_widgets/
resolve.rs

1//! Render-time resolution of a widget config: scheduled entries, `{{…}}` placeholders.
2//!
3//! Every renderer (WidgetKit, Glance, desktop webview, Adaptive Cards) runs the same steps
4//! on the stored JSON right before drawing, with what only the device knows (time, locale,
5//! placement, fetched data). This module is the reference; the Swift, Kotlin and TypeScript
6//! ports are checked against the same fixtures in `tests/fixtures/resolve/`.
7//!
8//! 1. **Entries** — the last entry with `at <= now` replaces the base `small` / `medium` /
9//!    `large` it carries.
10//! 2. **Placeholders** — in every string of the layouts:
11//!    `{{t.key}}` (localized `strings`), `{{data.a.b}}` / `{{data.list.0}}` (data),
12//!    `{{locale}}`, `{{instance}}`. A missing `t.key` renders the key, missing data renders
13//!    an empty string. `{{{{` is not an escape; placeholders do not nest.
14//!    `bind: {"value": "data.progress"}` on any element sets that field to the data value
15//!    with its JSON type (for numbers, booleans, arrays); a missing value keeps the literal.
16//! 3. **Contract v2** (array elements and family roots):
17//!    - `when: {path, eq|ne|gt|gte|lt|lte|exists|in}` or `{all|any: [...]}` / `{not: ...}`;
18//!      false removes the element. A bare `path` tests truthiness. `when` is stripped.
19//!    - `{"type":"repeat","each":"data.list","as":"item","limit":5,"template":{...}}` splices
20//!      one copy of `template` per item (at most 50), with `{{item.x}}` and `{{index}}`.
21//!    - `{"type":"use","template":"row","args":{...}}` expands `templates.row`; an exact
22//!      `"{{expr}}"` arg keeps its JSON type, other keys on `use` override the template root.
23//!      Nesting stops at depth 8. Loop variables and `arg` shadow the built-in roots.
24//!    - `requires: {resolve: n}` with `n` above [`CONTRACT_VERSION`] renders `fallback`.
25//! 3a. **Contract v3**: root `state` beside `data` (`{{state.k}}`, `when`, `bind`,
26//!    `repeat.each`), read from [`ResolveContext::state`] (the config's `state` defaults
27//!    merged with the stored state). Resolve only reads it; `set` on button / toggle / link
28//!    is kept for the renderer, which writes the state on tap.
29//! 4. The result keeps only `version` / `small` / `medium` / `large`, so the existing
30//!    decoders read it unchanged.
31
32use serde_json::{Map, Value};
33
34/// What a renderer knows at draw time.
35#[derive(Debug, Clone, Default)]
36pub struct ResolveContext<'a> {
37    /// Current time, Unix epoch milliseconds.
38    pub now_ms: u64,
39    /// Device locale (BCP-47 `pt-BR` or ICU `pt_BR`).
40    pub locale: &'a str,
41    /// Placement id: Android `appWidgetId`, the Apple Edit Widget choice, a window label.
42    pub instance: Option<&'a str>,
43    /// Data layers already merged with [`merge_data`].
44    pub data: Option<&'a Value>,
45    /// Local state already merged with [`merge_data`]: config `state`, then the stored
46    /// `state:{widgetId}`, then `state:{widgetId}#{instance}`.
47    pub state: Option<&'a Value>,
48}
49
50/// Layout families resolved and kept in the output (`accessory`: Apple Lock Screen).
51const FAMILIES: [&str; 4] = ["small", "medium", "large", "accessory"];
52
53/// Base layouts with the active entry applied.
54pub fn active_layouts(config: &Value, now_ms: u64) -> Map<String, Value> {
55    let mut out = Map::new();
56    for f in FAMILIES {
57        if let Some(v) = config.get(f).filter(|v| !v.is_null()) {
58            out.insert(f.into(), v.clone());
59        }
60    }
61    let active = entries(config)
62        .filter(|(at, _)| *at <= now_ms)
63        .max_by_key(|(at, _)| *at);
64    if let Some((_, entry)) = active {
65        for f in FAMILIES {
66            if let Some(v) = entry.get(f).filter(|v| !v.is_null()) {
67                out.insert(f.into(), v.clone());
68            }
69        }
70    }
71    out
72}
73
74/// The next time the active entry changes (first `at` after `now`), for scheduling a
75/// redraw.
76pub fn next_change_ms(config: &Value, now_ms: u64) -> Option<u64> {
77    entries(config)
78        .map(|(at, _)| at)
79        .filter(|at| *at > now_ms)
80        .min()
81}
82
83/// Entry start times after `now` in ascending order, at most `limit` (WidgetKit timeline).
84pub fn upcoming_changes(config: &Value, now_ms: u64, limit: usize) -> Vec<u64> {
85    let mut v: Vec<u64> = entries(config)
86        .map(|(at, _)| at)
87        .filter(|at| *at > now_ms)
88        .collect();
89    v.sort_unstable();
90    v.dedup();
91    v.truncate(limit);
92    v
93}
94
95fn entries(config: &Value) -> impl Iterator<Item = (u64, &Value)> {
96    config
97        .get("entries")
98        .and_then(Value::as_array)
99        .into_iter()
100        .flatten()
101        .filter_map(|e| Some((e.get("at")?.as_u64()?, e)))
102}
103
104/// Shallow merge of data layers, later layers win per top-level key. Non-object layers
105/// replace everything before them.
106pub fn merge_data<'a>(layers: impl IntoIterator<Item = Option<&'a Value>>) -> Option<Value> {
107    let mut out: Option<Value> = None;
108    for layer in layers.into_iter().flatten().filter(|l| !l.is_null()) {
109        match (out.as_mut(), layer) {
110            (Some(Value::Object(acc)), Value::Object(m)) => {
111                for (k, v) in m {
112                    acc.insert(k.clone(), v.clone());
113                }
114            }
115            _ => out = Some(layer.clone()),
116        }
117    }
118    out
119}
120
121/// Lower case, `_` → `-`, Unicode / private-use extensions (`-u-…`, `-x-…`) dropped.
122fn normalize_locale(tag: &str) -> String {
123    let t = tag.trim().replace('_', "-").to_ascii_lowercase();
124    let cut = ["-u-", "-x-"]
125        .iter()
126        .filter_map(|m| t.find(m))
127        .min()
128        .unwrap_or(t.len());
129    t[..cut].to_string()
130}
131
132/// `zh-hant-tw` → `zh-tw`: the tag without a 4-letter script subtag.
133fn language_region(tag: &str) -> String {
134    let parts: Vec<&str> = tag.split('-').collect();
135    parts
136        .iter()
137        .enumerate()
138        .filter(|(i, p)| !(*i == 1 && p.len() == 4 && p.chars().all(|c| c.is_ascii_alphabetic())))
139        .map(|(_, p)| *p)
140        .collect::<Vec<_>>()
141        .join("-")
142}
143
144fn language(tag: &str) -> &str {
145    tag.split('-').next().unwrap_or(tag)
146}
147
148/// Name of the `strings` table for `locale`: exact tag, then same language, then
149/// `defaultLocale`, then `"en"`, then the lexicographically first table. Matching ignores
150/// case and `_` vs `-`.
151pub fn pick_locale_table(config: &Value, locale: &str) -> Option<String> {
152    let tables = config.get("strings")?.as_object()?;
153    if tables.is_empty() {
154        return None;
155    }
156    let want = normalize_locale(locale);
157    let mut keys: Vec<&String> = tables.keys().collect();
158    keys.sort();
159    let find = |pred: &dyn Fn(&str) -> bool| {
160        keys.iter()
161            .find(|k| pred(&normalize_locale(k)))
162            .map(|k| (*k).clone())
163    };
164    if !want.is_empty() {
165        if let Some(k) = find(&|k| k == want) {
166            return Some(k);
167        }
168        let lr = language_region(&want);
169        if let Some(k) = find(&|k| language_region(k) == lr) {
170            return Some(k);
171        }
172        let lang = language(&want);
173        if let Some(k) = find(&|k| k == lang) {
174            return Some(k);
175        }
176        if let Some(k) = find(&|k| language(k) == lang) {
177            return Some(k);
178        }
179    }
180    if let Some(def) = config.get("defaultLocale").and_then(Value::as_str) {
181        let def = normalize_locale(def);
182        if let Some(k) = find(&|k| k == def) {
183            return Some(k);
184        }
185    }
186    find(&|k| k == "en").or_else(|| keys.first().map(|k| (*k).clone()))
187}
188
189fn lookup_path<'v>(root: &'v Value, path: &str) -> Option<&'v Value> {
190    if path.is_empty() {
191        return Some(root);
192    }
193    path.split('.').try_fold(root, |cur, seg| match cur {
194        Value::Object(m) => m.get(seg),
195        Value::Array(a) if !seg.is_empty() && seg.bytes().all(|b| b.is_ascii_digit()) => {
196            seg.parse::<usize>().ok().and_then(|i| a.get(i))
197        }
198        _ => None,
199    })
200}
201
202fn value_text(v: &Value) -> String {
203    match v {
204        Value::Null => String::new(),
205        Value::String(s) => s.clone(),
206        Value::Number(n) => match n.as_f64() {
207            // `72.0` from JavaScript arrives as a float; draw it like JS does.
208            // Whole floats (`72.0` from JavaScript) print as integers, like JS, up to 1e21.
209            Some(f) if n.is_f64() && f.fract() == 0.0 && f.abs() < 1e21 => {
210                format!("{:.0}", f + 0.0)
211            }
212            // Like JS: exponent outside 1e-6 ≤ |x| < 1e21 (`4e-323`, `1.5e+21`).
213            Some(f) if n.is_f64() && f != 0.0 && (f.abs() < 1e-6 || f.abs() >= 1e21) => {
214                let e = format!("{f:e}");
215                match e.split_once('e') {
216                    Some((m, x)) if !x.starts_with('-') => format!("{m}e+{x}"),
217                    _ => e,
218                }
219            }
220            // Shortest round-trip decimal, no exponent (`0.0001`, `12345678.5`).
221            Some(f) if n.is_f64() => format!("{f}"),
222            _ => n.to_string(),
223        },
224        other => other.to_string(),
225    }
226}
227
228/// Renderer contract version: a config with `requires.resolve` above it shows `fallback`.
229pub const CONTRACT_VERSION: u64 = 3;
230/// Hard cap of items one `repeat` produces.
231const MAX_REPEAT: usize = 50;
232/// Nested `use` depth (also stops template cycles).
233const MAX_TEMPLATE_DEPTH: usize = 8;
234
235#[derive(Clone)]
236struct Scope<'a> {
237    strings: Option<&'a Map<String, Value>>,
238    fallback_strings: Option<&'a Map<String, Value>>,
239    ctx: &'a ResolveContext<'a>,
240    templates: Option<&'a Map<String, Value>>,
241    /// Loop / template variables, innermost last (`task`, `arg`, …).
242    locals: Vec<(String, Value)>,
243    /// Index of the innermost `repeat`.
244    index: Option<usize>,
245    depth: usize,
246}
247
248/// Path under a typed root: `data` → `""`, `data.a.b` → `"a.b"`, `datax` → `None`.
249fn data_root_path<'e>(expr: &'e str, root: &str) -> Option<&'e str> {
250    match expr.strip_prefix(root)? {
251        "" => Some(""),
252        rest => rest.strip_prefix('.'),
253    }
254}
255
256/// `data…` or `state…`: roots whose values keep their JSON type.
257fn is_data_root(expr: &str) -> bool {
258    ["data", "state"]
259        .iter()
260        .any(|r| data_root_path(expr, r).is_some())
261}
262
263fn split_head(expr: &str) -> (&str, Option<&str>) {
264    match expr.split_once('.') {
265        Some((h, rest)) => (h, Some(rest)),
266        None => (expr, None),
267    }
268}
269
270fn truthy(v: Option<&Value>) -> bool {
271    match v {
272        None | Some(Value::Null) | Some(Value::Bool(false)) => false,
273        Some(Value::Number(n)) => n.as_f64() != Some(0.0),
274        Some(Value::String(s)) => !s.is_empty(),
275        Some(Value::Array(a)) => !a.is_empty(),
276        Some(_) => true,
277    }
278}
279
280/// JSON equality where numbers compare by value (`1 == 1.0`).
281fn json_eq(a: &Value, b: &Value) -> bool {
282    match (a, b) {
283        (Value::Number(x), Value::Number(y)) => x.as_f64() == y.as_f64(),
284        (Value::Array(x), Value::Array(y)) => {
285            x.len() == y.len() && x.iter().zip(y).all(|(a, b)| json_eq(a, b))
286        }
287        (Value::Object(x), Value::Object(y)) => {
288            x.len() == y.len()
289                && x.iter()
290                    .all(|(k, v)| y.get(k).is_some_and(|w| json_eq(v, w)))
291        }
292        _ => a == b,
293    }
294}
295
296impl<'a> Scope<'a> {
297    fn child(&self, name: &str, value: Value, index: Option<usize>) -> Scope<'a> {
298        let mut s = self.clone();
299        s.locals.push((name.to_string(), value));
300        if index.is_some() {
301            s.index = index;
302        }
303        s
304    }
305
306    fn local(&self, name: &str) -> Option<&Value> {
307        self.locals
308            .iter()
309            .rev()
310            .find(|(n, _)| n == name)
311            .map(|(_, v)| v)
312    }
313
314    fn string(&self, key: &str) -> Option<&Value> {
315        self.strings
316            .and_then(|m| m.get(key))
317            .or_else(|| self.fallback_strings.and_then(|m| m.get(key)))
318    }
319
320    /// Typed value of an expression; locals shadow the built-in roots.
321    fn lookup_expr(&self, expr: &str) -> Option<Value> {
322        let expr = expr.trim();
323        let (head, rest) = split_head(expr);
324        if let Some(l) = self.local(head) {
325            return lookup_path(l, rest.unwrap_or_default()).cloned();
326        }
327        match expr {
328            "index" => return self.index.map(Value::from),
329            "locale" => return Some(Value::String(self.ctx.locale.to_string())),
330            "instance" => {
331                return Some(Value::String(
332                    self.ctx.instance.unwrap_or_default().to_string(),
333                ))
334            }
335            _ => {}
336        }
337        if let Some(key) = expr.strip_prefix("t.") {
338            return self.string(key).cloned();
339        }
340        for (root, value) in [("data", self.ctx.data), ("state", self.ctx.state)] {
341            if let Some(path) = data_root_path(expr, root) {
342                return value.and_then(|d| lookup_path(d, path)).cloned();
343            }
344        }
345        None
346    }
347
348    fn known_root(&self, expr: &str) -> bool {
349        let (head, _) = split_head(expr);
350        self.local(head).is_some()
351            || matches!(expr, "locale" | "instance")
352            || is_data_root(expr)
353            || (expr == "index" && self.index.is_some())
354    }
355
356    fn placeholder(&self, expr: &str) -> String {
357        let expr = expr.trim();
358        let (head, _) = split_head(expr);
359        if self.local(head).is_none() {
360            if let Some(key) = expr.strip_prefix("t.") {
361                return self
362                    .string(key)
363                    .map(value_text)
364                    .unwrap_or_else(|| key.to_string());
365            }
366        }
367        if !self.known_root(expr) {
368            // Unknown root: keep the text so a typo stays visible.
369            return format!("{{{{{expr}}}}}");
370        }
371        self.lookup_expr(expr)
372            .map(|v| value_text(&v))
373            .unwrap_or_default()
374    }
375
376    fn interpolate(&self, s: &str) -> String {
377        let mut out = String::with_capacity(s.len());
378        let mut rest = s;
379        while let Some(start) = rest.find("{{") {
380            let Some(len) = rest[start + 2..].find("}}") else {
381                break;
382            };
383            out.push_str(&rest[..start]);
384            out.push_str(&self.placeholder(&rest[start + 2..start + 2 + len]));
385            rest = &rest[start + 2 + len + 2..];
386        }
387        out.push_str(rest);
388        out
389    }
390
391    /// Structured condition (see the module docs). Bad shapes are false.
392    fn eval_when(&self, cond: &Value) -> bool {
393        let Some(c) = cond.as_object() else {
394            return false;
395        };
396        if let Some(all) = c.get("all").and_then(Value::as_array) {
397            return all.iter().all(|x| self.eval_when(x));
398        }
399        if let Some(any) = c.get("any").and_then(Value::as_array) {
400            return any.iter().any(|x| self.eval_when(x));
401        }
402        if let Some(not) = c.get("not") {
403            return !self.eval_when(not);
404        }
405        let Some(path) = c.get("path").and_then(Value::as_str) else {
406            return false;
407        };
408        let v = self.lookup_expr(path);
409        let has = v.as_ref().is_some_and(|v| !v.is_null());
410        let ops = ["eq", "ne", "gt", "gte", "lt", "lte", "exists", "in"];
411        if !ops.iter().any(|op| c.contains_key(*op)) {
412            return truthy(v.as_ref());
413        }
414        ops.iter()
415            .filter_map(|op| c.get(*op).map(|x| (*op, x)))
416            .all(|(op, x)| match op {
417                "exists" => has == (x == &Value::Bool(true)),
418                "eq" => has && json_eq(v.as_ref().unwrap(), x),
419                "ne" => has && !json_eq(v.as_ref().unwrap(), x),
420                "in" => {
421                    has && x
422                        .as_array()
423                        .is_some_and(|a| a.iter().any(|y| json_eq(v.as_ref().unwrap(), y)))
424                }
425                _ => match (v.as_ref().and_then(Value::as_f64), x.as_f64()) {
426                    (Some(a), Some(b)) => match op {
427                        "gt" => a > b,
428                        "gte" => a >= b,
429                        "lt" => a < b,
430                        _ => a <= b,
431                    },
432                    _ => false,
433                },
434            })
435    }
436
437    /// One array element → zero or more (`repeat` splices, `when` drops, `use` expands).
438    fn expand(&self, el: &Value) -> Vec<Value> {
439        let Some(m) = el.as_object() else {
440            return vec![self.walk(el)];
441        };
442        if let Some(cond) = m.get("when") {
443            if !self.eval_when(cond) {
444                return Vec::new();
445            }
446        }
447        match m.get("type").and_then(Value::as_str) {
448            Some("repeat") => {
449                let items = m
450                    .get("each")
451                    .and_then(Value::as_str)
452                    .and_then(|e| self.lookup_expr(e));
453                let (Some(Value::Array(items)), Some(template)) = (items, m.get("template")) else {
454                    return Vec::new();
455                };
456                if !template.is_object() {
457                    return Vec::new();
458                }
459                let name = m
460                    .get("as")
461                    .and_then(Value::as_str)
462                    .filter(|s| !s.is_empty())
463                    .unwrap_or("item");
464                let limit = m
465                    .get("limit")
466                    .and_then(Value::as_u64)
467                    .map_or(MAX_REPEAT, |l| (l as usize).min(MAX_REPEAT));
468                items
469                    .into_iter()
470                    .take(limit)
471                    .enumerate()
472                    .flat_map(|(i, item)| self.child(name, item, Some(i)).expand(template))
473                    .collect()
474            }
475            Some("use") => {
476                let tpl = m
477                    .get("template")
478                    .and_then(Value::as_str)
479                    .and_then(|n| self.templates?.get(n))
480                    .and_then(Value::as_object);
481                let Some(tpl) = tpl.filter(|_| self.depth < MAX_TEMPLATE_DEPTH) else {
482                    return Vec::new();
483                };
484                let mut args = Map::new();
485                if let Some(a) = m.get("args").and_then(Value::as_object) {
486                    for (k, v) in a {
487                        args.insert(k.clone(), self.arg_value(v));
488                    }
489                }
490                // Keys on the `use` element override the template root (instance wins).
491                let mut root = tpl.clone();
492                for (k, v) in m {
493                    if !matches!(k.as_str(), "type" | "template" | "args" | "when") {
494                        root.insert(k.clone(), v.clone());
495                    }
496                }
497                let mut inner = self.child("arg", Value::Object(args), None);
498                inner.depth += 1;
499                inner.expand(&Value::Object(root))
500            }
501            _ => vec![self.walk(el)],
502        }
503    }
504
505    /// An argument that is exactly one `{{expr}}` keeps the expression's JSON type.
506    fn arg_value(&self, v: &Value) -> Value {
507        let Some(s) = v.as_str() else {
508            return v.clone();
509        };
510        if let Some(inner) = s.strip_prefix("{{").and_then(|r| r.strip_suffix("}}")) {
511            if !inner.contains("{{") && !inner.contains("}}") {
512                return self
513                    .lookup_expr(inner)
514                    .unwrap_or_else(|| Value::String(String::new()));
515            }
516        }
517        Value::String(self.interpolate(s))
518    }
519
520    fn walk(&self, v: &Value) -> Value {
521        match v {
522            Value::String(s) => Value::String(self.interpolate(s)),
523            Value::Array(a) => Value::Array(a.iter().flat_map(|x| self.expand(x)).collect()),
524            Value::Object(m) => {
525                let mut out = Map::new();
526                for (k, x) in m {
527                    if k != "bind" && k != "when" {
528                        out.insert(k.clone(), self.walk(x));
529                    }
530                }
531                if let Some(Value::Object(b)) = m.get("bind") {
532                    for (field, expr) in b {
533                        if let Some(val) = expr.as_str().and_then(|e| self.bound(e)) {
534                            out.insert(field.clone(), val);
535                        }
536                    }
537                }
538                Value::Object(out)
539            }
540            other => other.clone(),
541        }
542    }
543
544    /// Value for `bind: {field: expr}`: data / loop / arg values keep their JSON type
545    /// (numbers stay numbers); other expressions render as text. A missing value keeps
546    /// the literal.
547    fn bound(&self, expr: &str) -> Option<Value> {
548        let expr = expr.trim();
549        let (head, _) = split_head(expr);
550        let typed = self.local(head).is_some()
551            || is_data_root(expr)
552            || (expr == "index" && self.index.is_some());
553        if typed {
554            return self.lookup_expr(expr).filter(|v| !v.is_null());
555        }
556        Some(Value::String(self.placeholder(expr)))
557    }
558
559    /// A family root: `use` / `repeat` / `when` apply there too (a dropped root is omitted).
560    fn walk_root(&self, v: &Value) -> Option<Value> {
561        if v.get("type").and_then(Value::as_str) == Some("repeat") {
562            return Some(serde_json::json!({"type": "vstack", "children": self.expand(v)}));
563        }
564        self.expand(v).into_iter().next()
565    }
566}
567
568fn strings_table<'a>(config: &'a Value, name: Option<&str>) -> Option<&'a Map<String, Value>> {
569    config.get("strings")?.get(name?)?.as_object()
570}
571
572fn scope_for<'a>(config: &'a Value, ctx: &'a ResolveContext<'a>) -> Scope<'a> {
573    let table = pick_locale_table(config, ctx.locale);
574    let def = pick_locale_table(config, "");
575    Scope {
576        strings: strings_table(config, table.as_deref()),
577        fallback_strings: strings_table(config, def.as_deref()),
578        ctx,
579        templates: config.get("templates").and_then(Value::as_object),
580        locals: Vec::new(),
581        index: None,
582        depth: 0,
583    }
584}
585
586/// Replace placeholders in one string (used for [`crate::models::DataSource`] fields).
587pub fn interpolate(config: &Value, ctx: &ResolveContext<'_>, s: &str) -> String {
588    scope_for(config, ctx).interpolate(s)
589}
590
591/// The config this renderer can show: `fallback` (or a built-in notice) when
592/// `requires.resolve` is above [`CONTRACT_VERSION`].
593fn effective_config(config: &Value) -> std::borrow::Cow<'_, Value> {
594    let need = config
595        .get("requires")
596        .and_then(|r| r.get("resolve"))
597        .and_then(Value::as_u64);
598    if !need.is_some_and(|n| n > CONTRACT_VERSION) {
599        return std::borrow::Cow::Borrowed(config);
600    }
601    let mut out = match config.get("fallback") {
602        Some(Value::Object(fb)) => fb.clone(),
603        _ => serde_json::json!({"small": {"type": "text", "content": "Update the app to show this widget"}})
604            .as_object()
605            .cloned()
606            .unwrap_or_default(),
607    };
608    for k in ["version", "strings", "defaultLocale"] {
609        if let Some(v) = config.get(k) {
610            out.insert(k.into(), v.clone());
611        }
612    }
613    std::borrow::Cow::Owned(Value::Object(out))
614}
615
616/// Full resolution: active entry, then templates / repeat / when / placeholders / bind.
617/// Output has only `version` and the size families.
618pub fn resolve(config: &Value, ctx: &ResolveContext<'_>) -> Value {
619    let config = effective_config(config);
620    let config = config.as_ref();
621    let scope = scope_for(config, ctx);
622    let layouts = active_layouts(config, ctx.now_ms);
623    let mut out = Map::new();
624    out.insert(
625        "version".into(),
626        config
627            .get("version")
628            .filter(|v| !v.is_null())
629            .cloned()
630            .unwrap_or(Value::from(1)),
631    );
632    for (family, v) in layouts {
633        if let Some(r) = scope.walk_root(&v) {
634            out.insert(family, r);
635        }
636    }
637    Value::Object(out)
638}
639
640/// Resolve a free-form object (a Live Activity template, see
641/// [`crate::models::resolve_activity`]): element roots (objects with a `type`) like a family
642/// root, everything else like any value in a layout. `strings` / `templates` /
643/// `defaultLocale` in it work as in a config and are not part of the output.
644pub fn resolve_fields(fields: &Value, ctx: &ResolveContext<'_>) -> Value {
645    let scope = scope_for(fields, ctx);
646    let Some(m) = fields.as_object() else {
647        return scope.walk(fields);
648    };
649    let mut out = Map::new();
650    for (k, v) in m {
651        if matches!(k.as_str(), "strings" | "templates" | "defaultLocale") {
652            continue;
653        }
654        let r = if v.get("type").is_some() {
655            scope.walk_root(v)
656        } else {
657            Some(scope.walk(v))
658        };
659        if let Some(r) = r {
660            out.insert(k.clone(), r);
661        }
662    }
663    Value::Object(out)
664}
665
666/// `https://` anywhere; plain `http://` only to `localhost` / `127.0.0.1` (development).
667/// The host is compared exactly, so `http://localhost.evil.com` and
668/// `http://localhost@evil.com` are refused.
669pub fn source_url_allowed(url: &str) -> bool {
670    if url.starts_with("https://") {
671        return true;
672    }
673    let Some(rest) = url.strip_prefix("http://") else {
674        return false;
675    };
676    let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
677    if authority.contains('@') {
678        return false;
679    }
680    let (host, port) = authority.split_once(':').unwrap_or((authority, ""));
681    let host_ok = host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1";
682    host_ok && port.chars().all(|c| c.is_ascii_digit())
683}
684
685/// Next time a host-rendered surface needs work: the next entry change, or when `source`
686/// is due (`last_fetch_ms + refreshMinutes`; now when never fetched or overdue).
687pub fn next_wake_ms(config: &Value, now_ms: u64, last_fetch_ms: Option<u64>) -> Option<u64> {
688    // Only placeholders of the source fields are filled; they never read the time.
689    let ctx = ResolveContext::default();
690    let source = source_request(config, &ctx).map(|s| {
691        last_fetch_ms
692            .map(|t| t.saturating_add(u64::from(s.refresh_minutes) * 60_000))
693            .unwrap_or(now_ms)
694            .max(now_ms)
695    });
696    [next_change_ms(config, now_ms), source]
697        .into_iter()
698        .flatten()
699        .min()
700}
701
702/// A [`crate::models::DataSource`] with placeholders filled, ready to send.
703#[derive(Debug, Clone, PartialEq)]
704pub struct SourceRequest {
705    /// Request URL.
706    pub url: String,
707    /// `GET` or `POST`.
708    pub method: String,
709    /// Header name → value.
710    pub headers: Vec<(String, String)>,
711    /// Body for `POST`.
712    pub body: Option<String>,
713    /// Minutes between fetches (15..=1440).
714    pub refresh_minutes: u32,
715}
716
717/// Default minutes between source fetches.
718pub const DEFAULT_REFRESH_MINUTES: u32 = 30;
719/// Largest response body a renderer accepts.
720pub const MAX_SOURCE_BYTES: usize = 256 * 1024;
721
722/// The fetch a renderer should make for this config, or `None` without a valid `source`.
723/// Only `https://` is allowed (`http://localhost` too, for development).
724pub fn source_request(config: &Value, ctx: &ResolveContext<'_>) -> Option<SourceRequest> {
725    let src = config.get("source")?;
726    let url = interpolate(config, ctx, src.get("url")?.as_str()?);
727    if !source_url_allowed(&url) {
728        return None;
729    }
730    let method = match src.get("method").and_then(Value::as_str) {
731        Some(m) if m.eq_ignore_ascii_case("post") => "POST",
732        _ => "GET",
733    }
734    .to_string();
735    let headers = src
736        .get("headers")
737        .and_then(Value::as_object)
738        .map(|m| {
739            m.iter()
740                .filter_map(|(k, v)| Some((k.clone(), interpolate(config, ctx, v.as_str()?))))
741                .collect()
742        })
743        .unwrap_or_default();
744    let body = if method == "POST" {
745        src.get("body")
746            .and_then(Value::as_str)
747            .map(|b| interpolate(config, ctx, b))
748    } else {
749        None
750    };
751    let refresh_minutes = src
752        .get("refreshMinutes")
753        .and_then(Value::as_u64)
754        .map(|m| m.clamp(15, 1440) as u32)
755        .unwrap_or(DEFAULT_REFRESH_MINUTES);
756    Some(SourceRequest {
757        url,
758        method,
759        headers,
760        body,
761        refresh_minutes,
762    })
763}
764
765#[cfg(test)]
766mod tests {
767    use super::*;
768    use serde_json::json;
769    use std::path::PathBuf;
770
771    fn ctx<'a>(now: u64, locale: &'a str, data: Option<&'a Value>) -> ResolveContext<'a> {
772        ResolveContext {
773            now_ms: now,
774            locale,
775            instance: None,
776            data,
777            state: None,
778        }
779    }
780
781    /// `small.content` of `cfg` resolved with `data`.
782    fn small_text(cfg: &Value, data: &Value) -> Value {
783        resolve(cfg, &ctx(0, "en", Some(data)))["small"]["content"].clone()
784    }
785
786    /// Fixture format v2: whether this port runs a case. `platforms` lists the ports that
787    /// run it (all when absent); `skip: {port: reason}` skips one with a reason.
788    /// `Err` when the port neither runs it nor has a reason.
789    pub(crate) fn fixture_runs(case: &Value, port: &str) -> Result<bool, String> {
790        let reason = case
791            .get("skip")
792            .and_then(|s| s.get(port))
793            .and_then(Value::as_str)
794            .filter(|r| !r.trim().is_empty());
795        if reason.is_some() {
796            return Ok(false);
797        }
798        match case.get("platforms").and_then(Value::as_array) {
799            None => Ok(true),
800            Some(p) if p.iter().any(|x| x == port) => Ok(true),
801            Some(_) => Err(format!("not in `platforms` and no `skip.{port}` reason")),
802        }
803    }
804
805    /// Shared contract: the Swift, Kotlin and TypeScript ports run the same files. First the
806    /// rules that pick which fixtures this port runs, then every fixture.
807    #[test]
808    fn fixtures_match() {
809        assert_eq!(fixture_runs(&json!({}), "rust"), Ok(true));
810        assert_eq!(
811            fixture_runs(&json!({"platforms": ["rust"]}), "rust"),
812            Ok(true)
813        );
814        assert_eq!(
815            fixture_runs(
816                &json!({"platforms": ["ts"], "skip": {"rust": "n/a"}}),
817                "rust"
818            ),
819            Ok(false)
820        );
821        assert!(fixture_runs(&json!({"platforms": ["ts"]}), "rust").is_err());
822        assert!(
823            fixture_runs(&json!({"platforms": ["ts"], "skip": {"rust": " "}}), "rust").is_err()
824        );
825        assert_eq!(
826            fixture_runs(&json!({"skip": {"swift": "x"}}), "rust"),
827            Ok(true)
828        );
829
830        let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/resolve");
831        let mut files: Vec<PathBuf> = std::fs::read_dir(&dir)
832            .unwrap()
833            .map(|e| e.unwrap().path())
834            .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("json"))
835            .collect();
836        files.sort();
837        let (mut ran, mut skipped, mut unaccounted) = (Vec::new(), Vec::new(), Vec::new());
838        for path in &files {
839            let name = path.file_name().unwrap().to_string_lossy().to_string();
840            let case: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap())
841                .unwrap_or_else(|e| panic!("{}: {e}", path.display()));
842            match fixture_runs(&case, "rust") {
843                Ok(true) => {}
844                Ok(false) => {
845                    skipped.push(name);
846                    continue;
847                }
848                Err(e) => {
849                    unaccounted.push(format!("{name}: {e}"));
850                    continue;
851                }
852            }
853            let c = &case["ctx"];
854            let config = &case["config"];
855            let data = merge_data([config.get("data"), c.get("data")]);
856            let state = merge_data([config.get("state"), c.get("state")]);
857            let rc = ResolveContext {
858                now_ms: c["nowMs"].as_u64().unwrap(),
859                locale: c["locale"].as_str().unwrap_or(""),
860                instance: c.get("instance").and_then(Value::as_str),
861                data: data.as_ref(),
862                state: state.as_ref(),
863            };
864            assert_eq!(resolve(config, &rc), case["expected"], "{}", path.display());
865            let next = next_change_ms(config, rc.now_ms);
866            assert_eq!(
867                next.map(Value::from).unwrap_or(Value::Null),
868                case.get("nextChangeMs").cloned().unwrap_or(Value::Null),
869                "{} nextChangeMs",
870                path.display()
871            );
872            if let Some(want) = case.get("source") {
873                let got = source_request(config, &rc).map(|s| {
874                    json!({
875                        "url": s.url, "method": s.method, "body": s.body,
876                        "refreshMinutes": s.refresh_minutes,
877                        "headers": s.headers.into_iter()
878                            .map(|(k, v)| (k, Value::String(v))).collect::<Map<_, _>>(),
879                    })
880                });
881                assert_eq!(
882                    got.unwrap_or(Value::Null),
883                    *want,
884                    "{} source",
885                    path.display()
886                );
887            }
888            ran.push(name);
889        }
890        // Executed-case manifest (`cargo test -- --nocapture` shows it).
891        println!("resolve fixtures ran: {}", ran.join(", "));
892        println!("resolve fixtures skipped: {}", skipped.join(", "));
893        assert!(
894            unaccounted.is_empty(),
895            "fixtures neither run nor skipped with a reason: {unaccounted:?}"
896        );
897        assert_eq!(ran.len() + skipped.len(), files.len());
898        assert!(
899            ran.len() >= 5,
900            "expected resolve fixtures, ran {}",
901            ran.len()
902        );
903    }
904
905    /// A widget's day: base layouts, scheduled entries switching in (in any order, exactly at
906    /// `at`), the redraw schedule, and the source fetch joining the wake-up times.
907    #[test]
908    fn timeline_scenario() {
909        let cfg = json!({
910            "version": 2,
911            "small": {"type": "text", "content": "s"},
912            "medium": {"type": "text", "content": "m"},
913            "entries": [
914                {"at": 300, "small": {"type": "text", "content": "c"}},
915                {"at": 100, "small": {"type": "text", "content": "a"}},
916                {"at": 200, "small": {"type": "text", "content": "b"},
917                            "medium": {"type": "text", "content": "m2"}}
918            ]
919        });
920        let texts = |now: u64| {
921            let l = active_layouts(&cfg, now);
922            assert!(!l.contains_key("large"));
923            (
924                l["small"]["content"].clone(),
925                l["medium"]["content"].clone(),
926            )
927        };
928        // Before the first entry: base layouts, next change at the first `at`.
929        assert_eq!(texts(99), (json!("s"), json!("m")));
930        assert_eq!(next_change_ms(&cfg, 99), Some(100));
931        assert_eq!(upcoming_changes(&cfg, 0, 2), vec![100, 200]);
932        // Exactly at `at` the entry is active and no longer upcoming; an entry without
933        // `medium` keeps the base one.
934        assert_eq!(texts(100), (json!("a"), json!("m")));
935        assert_eq!(upcoming_changes(&cfg, 100, 10), vec![200, 300]);
936        // The latest past entry wins, whatever the array order.
937        assert_eq!(texts(250), (json!("b"), json!("m2")));
938        assert_eq!(upcoming_changes(&cfg, 150, 10), vec![200, 300]);
939        // After the last entry nothing is scheduled; it replaces only what it carries.
940        assert_eq!(texts(300), (json!("c"), json!("m")));
941        assert_eq!(next_change_ms(&cfg, 300), None);
942        // The output keeps `version` and the families only.
943        assert_eq!(
944            resolve(&cfg, &ctx(250, "en", None)),
945            json!({"version": 2, "small": {"type": "text", "content": "b"},
946                   "medium": {"type": "text", "content": "m2"}})
947        );
948        assert_eq!(
949            resolve(&json!({}), &ctx(0, "en", None)),
950            json!({"version": 1})
951        );
952
953        // Wake-ups: entries plus a source due `refreshMinutes` after the last fetch.
954        let cfg = json!({"entries": [{"at": 5_000}], "source": {"url": "https://a", "refreshMinutes": 15}});
955        assert_eq!(
956            next_wake_ms(&cfg, 1_000, None),
957            Some(1_000),
958            "never fetched: now"
959        );
960        assert_eq!(
961            next_wake_ms(&cfg, 1_000, Some(0)),
962            Some(5_000),
963            "entry first"
964        );
965        assert_eq!(
966            next_wake_ms(&cfg, 6_000, Some(0)),
967            Some(900_000),
968            "then the source"
969        );
970        assert_eq!(
971            next_wake_ms(&cfg, 2_000_000, Some(0)),
972            Some(2_000_000),
973            "overdue: now"
974        );
975        assert_eq!(next_wake_ms(&json!({}), 0, None), None);
976        assert_eq!(
977            next_wake_ms(&json!({"entries": [{"at": 9}]}), 0, Some(0)),
978            Some(9)
979        );
980    }
981
982    /// Strings: the table chosen for a locale (exact, language, default, `en`, first), then
983    /// a missing key falling back to the default table and finally to the key itself.
984    #[test]
985    fn localization_scenario() {
986        let cfg = json!({
987            "defaultLocale": "de",
988            "strings": {"en": {}, "de": {}, "pt-BR": {}, "ru": {}}
989        });
990        assert_eq!(pick_locale_table(&cfg, "pt_BR").as_deref(), Some("pt-BR"));
991        assert_eq!(pick_locale_table(&cfg, "pt-PT").as_deref(), Some("pt-BR"));
992        assert_eq!(pick_locale_table(&cfg, "ru-RU").as_deref(), Some("ru"));
993        assert_eq!(pick_locale_table(&cfg, "fr").as_deref(), Some("de"));
994        assert_eq!(pick_locale_table(&cfg, "").as_deref(), Some("de"));
995        let no_default = json!({"strings": {"fr": {}, "en": {}}});
996        assert_eq!(pick_locale_table(&no_default, "ja").as_deref(), Some("en"));
997        let first = json!({"strings": {"fr": {}, "de": {}}});
998        assert_eq!(pick_locale_table(&first, "ja").as_deref(), Some("de"));
999        assert_eq!(pick_locale_table(&json!({}), "en"), None);
1000        assert_eq!(pick_locale_table(&json!({"strings": {}}), "en"), None);
1001
1002        let cfg = json!({
1003            "defaultLocale": "en",
1004            "strings": {"en": {"a": "A", "b": "B"}, "ru": {"a": "А"}},
1005            "small": {"type": "text", "content": "{{t.a}}{{t.b}}{{t.c}}"}
1006        });
1007        assert_eq!(
1008            resolve(&cfg, &ctx(0, "ru", None))["small"]["content"],
1009            "АBc"
1010        );
1011        assert_eq!(
1012            resolve(&cfg, &ctx(0, "en", None))["small"]["content"],
1013            "ABc"
1014        );
1015    }
1016
1017    /// How values print and bind: data and state roots, JS-like numbers, exact big
1018    /// integers, unknown roots and `index` outside a repeat left visible, `bind` keeping
1019    /// the JSON type.
1020    #[test]
1021    fn placeholders_and_bind_scenario() {
1022        let data = json!({"t": 72.0, "f": 1.5, "b": true, "n": null, "list": ["x", "y"],
1023            "o": {"k": 1}, "u": u64::MAX, "i": 9_007_199_254_740_993_i64});
1024        let text = |content: &str| json!({"small": {"type": "text", "content": content}});
1025        assert_eq!(
1026            small_text(&text("{{data.t}}|{{data.f}}|{{data.b}}|{{data.n}}|{{data.list.1}}|{{data.o}}|{{data.missing}}|{{data.list.9}}|{{ data.t }}"), &data),
1027            r#"72|1.5|true||y|{"k":1}|||72"#
1028        );
1029        assert_eq!(
1030            small_text(&text("{{data.u}} {{data.i}}"), &data),
1031            "18446744073709551615 9007199254740993"
1032        );
1033        assert_eq!(
1034            small_text(&text("{{oops}} {{index}} {{data.x"), &data),
1035            "{{oops}} {{index}} {{data.x"
1036        );
1037
1038        // State reads like data; `set` stays for the renderer.
1039        let state = json!({"on": true, "n": 2, "list": ["a"]});
1040        let c = ResolveContext {
1041            state: Some(&state),
1042            ..ctx(0, "en", Some(&data))
1043        };
1044        let cfg = json!({"small": {"type": "vstack", "children": [
1045            {"type": "text", "content": "{{state.n}}|{{state.x}}|{{statex}}"},
1046            {"type": "toggle", "isOn": false, "bind": {"isOn": "state.on"}, "set": {"on": "$toggle"}},
1047            {"type": "text", "content": "hidden", "when": {"path": "state.on", "eq": false}},
1048            {"type": "text", "content": "lit", "bind": {"content": "index"}},
1049            {"type": "progress", "value": 0.1, "bind": {"value": "data.missing"}},
1050            {"type": "repeat", "each": "state.list", "template":
1051                {"type": "progress", "value": 0.5, "label": "{{oops}}", "bind": {"value": "index"}}}
1052        ]}});
1053        let out = resolve(&cfg, &c);
1054        let ch = out["small"]["children"].as_array().unwrap();
1055        assert_eq!(ch.len(), 5, "the `when` child is dropped");
1056        assert_eq!(ch[0]["content"], "2||{{statex}}");
1057        assert_eq!(ch[1]["isOn"], true);
1058        assert_eq!(ch[1]["set"]["on"], "$toggle");
1059        // Outside a repeat `index` is not a root: bound as visible text.
1060        assert_eq!(ch[2]["content"], "{{index}}");
1061        assert_eq!(ch[3]["value"], 0.1, "missing bound value keeps the literal");
1062        // Inside a repeat `index` binds as a number; unknown roots still stay visible.
1063        assert_eq!(ch[4]["value"], json!(0));
1064        assert_eq!(ch[4]["label"], "{{oops}}");
1065    }
1066
1067    /// `when` on array children: every operator on both sides of its boundary, missing
1068    /// values, truthiness, and deep equality where numbers compare by value.
1069    #[test]
1070    fn conditions_scenario() {
1071        let data = json!({"n": 3, "s": "ok", "e": "", "arr": [1, 2], "obj": {"a": 1}});
1072        let t = |name: &str, when: Value| json!({"type": "text", "content": name, "when": when});
1073        let cfg = json!({"small": {"type": "vstack", "children": [
1074            t("gte-above", json!({"path": "data.n", "gte": 2})),
1075            t("lt-equal-no", json!({"path": "data.n", "lt": 3})),
1076            t("lt-above", json!({"path": "data.n", "lt": 4})),
1077            t("lte-equal", json!({"path": "data.n", "lte": 3})),
1078            t("lte-below-no", json!({"path": "data.n", "lte": 2})),
1079            t("ne-equal-no", json!({"path": "data.s", "ne": "ok"})),
1080            t("ne-missing-no", json!({"path": "data.x", "ne": "ok"})),
1081            t("in-no", json!({"path": "data.s", "in": ["x"]})),
1082            t("in-missing-no", json!({"path": "data.x", "in": ["ok"]})),
1083            t("str-truthy", json!({"path": "data.s"})),
1084            t("str-empty-no", json!({"path": "data.e"})),
1085            t("arr-num-eq", json!({"path": "data.arr", "eq": [1.0, 2.0]})),
1086            t("arr-prefix-no", json!({"path": "data.arr", "eq": [1]})),
1087            t("arr-other-no", json!({"path": "data.arr", "eq": [1, 3]})),
1088            t("obj-num-eq", json!({"path": "data.obj", "eq": {"a": 1.0}})),
1089            t("obj-superset-no", json!({"path": "data.obj", "eq": {"a": 1, "b": 2}})),
1090            t("obj-other-no", json!({"path": "data.obj", "eq": {"b": 1}})),
1091        ]}});
1092        let got: Vec<Value> = resolve(&cfg, &ctx(0, "en", Some(&data)))["small"]["children"]
1093            .as_array()
1094            .unwrap()
1095            .iter()
1096            .map(|c| c["content"].clone())
1097            .collect();
1098        assert_eq!(
1099            got,
1100            [
1101                "gte-above",
1102                "lt-above",
1103                "lte-equal",
1104                "str-truthy",
1105                "arr-num-eq",
1106                "obj-num-eq"
1107            ]
1108        );
1109    }
1110
1111    /// Reuse: `use` with typed and interpolated args, self-recursion cut at the depth cap,
1112    /// and `resolve_fields` on a Live Activity template.
1113    #[test]
1114    fn templates_scenario() {
1115        let data = json!({"a": 5, "eta": 5, "late": false});
1116        let cfg = json!({
1117            "templates": {
1118                "row": {"type": "text", "content": "{{arg.v}}"},
1119                "r": {"type": "vstack", "children": [{"type": "use", "template": "r"}]}
1120            },
1121            "small": {"type": "use", "template": "row", "args": {"v": "{{data.a}}x}}"}},
1122            "medium": {"type": "use", "template": "r"}
1123        });
1124        let out = resolve(&cfg, &ctx(0, "en", Some(&data)));
1125        // Not one exact `{{expr}}`: the arg is interpolated as text.
1126        assert_eq!(out["small"]["content"], "5x}}");
1127        let (mut depth, mut cur) = (0, &out["medium"]);
1128        while cur["type"] == "vstack" {
1129            depth += 1;
1130            cur = &cur["children"][0];
1131        }
1132        assert_eq!(depth, MAX_TEMPLATE_DEPTH);
1133
1134        let tpl = json!({
1135            "title": "ETA {{data.eta}}",
1136            "lockScreen": {"type": "text", "content": "{{data.eta}} min"},
1137            "minimal": {"type": "text", "content": "x", "when": {"path": "data.late"}},
1138            "strings": {"en": {"a": "b"}}
1139        });
1140        assert_eq!(
1141            resolve_fields(&tpl, &ctx(0, "en", Some(&data))),
1142            json!({"title": "ETA 5", "lockScreen": {"type": "text", "content": "5 min"}})
1143        );
1144    }
1145
1146    /// Fetch inputs: data layers merged, then the `source` request built from them — only
1147    /// `https://` or loopback `http://`, `POST` only when asked, refresh clamped.
1148    #[test]
1149    fn data_source_scenario() {
1150        let a = json!({"x": 1, "y": 1});
1151        let b = json!({"y": 2});
1152        assert_eq!(
1153            merge_data([Some(&a), None, Some(&b)]),
1154            Some(json!({"x": 1, "y": 2}))
1155        );
1156        let scalar = json!(5);
1157        assert_eq!(merge_data([Some(&a), Some(&scalar)]), Some(json!(5)));
1158        assert_eq!(merge_data([Some(&scalar), Some(&b)]), Some(json!({"y": 2})));
1159        assert_eq!(merge_data([None, None]), None);
1160
1161        let c = ctx(0, "ru", None);
1162        let get = |src: Value| source_request(&json!({ "source": src }), &c);
1163        assert!(get(json!({"url": "http://example.com"})).is_none());
1164        assert!(get(json!({"url": "ftp://x"})).is_none());
1165        assert!(get(json!({})).is_none());
1166        for bad in [
1167            "http://localhost.evil.com/a",
1168            "http://localhost@evil.com/a",
1169            "http://127.0.0.1.nip.io/a",
1170            "http://localhost:80x/a",
1171            "http://evil.com/?localhost",
1172        ] {
1173            assert!(get(json!({ "url": bad })).is_none(), "{bad}");
1174        }
1175        for ok in [
1176            "http://LOCALHOST/a",
1177            "http://localhost",
1178            "http://127.0.0.1:8080?x=1",
1179            "http://127.0.0.1/a",
1180        ] {
1181            assert!(get(json!({ "url": ok })).is_some(), "{ok}");
1182        }
1183        let r = get(json!({"url": "http://localhost:1/a"})).unwrap();
1184        assert_eq!(r.method, "GET");
1185        assert_eq!(r.refresh_minutes, DEFAULT_REFRESH_MINUTES);
1186        let r = get(json!({"url": "https://a/{{locale}}", "method": "post", "body": "{{locale}}", "refreshMinutes": 1})).unwrap();
1187        assert_eq!(
1188            (
1189                r.url.as_str(),
1190                r.method.as_str(),
1191                r.body.as_deref(),
1192                r.refresh_minutes
1193            ),
1194            ("https://a/ru", "POST", Some("ru"), 15)
1195        );
1196        let r = get(json!({"url": "https://a", "body": "x", "refreshMinutes": 99999})).unwrap();
1197        assert_eq!((r.body, r.refresh_minutes), (None, 1440));
1198        for m in ["GET", "put"] {
1199            let r = get(json!({"url": "https://a", "method": m, "body": "x"})).unwrap();
1200            assert_eq!((r.method.as_str(), r.body), ("GET", None), "{m}");
1201        }
1202        // Renderers refuse responses above 256 KiB.
1203        assert_eq!(MAX_SOURCE_BYTES, 262_144);
1204    }
1205}