1use serde_json::{Map, Value};
33
34#[derive(Debug, Clone, Default)]
36pub struct ResolveContext<'a> {
37 pub now_ms: u64,
39 pub locale: &'a str,
41 pub instance: Option<&'a str>,
43 pub data: Option<&'a Value>,
45 pub state: Option<&'a Value>,
48}
49
50const FAMILIES: [&str; 4] = ["small", "medium", "large", "accessory"];
52
53pub 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
74pub 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
83pub 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
104pub 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
121fn 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
132fn 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
148pub 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 Some(f) if n.is_f64() && f.fract() == 0.0 && f.abs() < 1e21 => {
210 format!("{:.0}", f + 0.0)
211 }
212 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 Some(f) if n.is_f64() => format!("{f}"),
222 _ => n.to_string(),
223 },
224 other => other.to_string(),
225 }
226}
227
228pub const CONTRACT_VERSION: u64 = 3;
230const MAX_REPEAT: usize = 50;
232const 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 locals: Vec<(String, Value)>,
243 index: Option<usize>,
245 depth: usize,
246}
247
248fn 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
256fn 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
280fn 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 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 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 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 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 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 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 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 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
586pub fn interpolate(config: &Value, ctx: &ResolveContext<'_>, s: &str) -> String {
588 scope_for(config, ctx).interpolate(s)
589}
590
591fn 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
616pub 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
640pub 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
666pub 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
685pub fn next_wake_ms(config: &Value, now_ms: u64, last_fetch_ms: Option<u64>) -> Option<u64> {
688 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#[derive(Debug, Clone, PartialEq)]
704pub struct SourceRequest {
705 pub url: String,
707 pub method: String,
709 pub headers: Vec<(String, String)>,
711 pub body: Option<String>,
713 pub refresh_minutes: u32,
715}
716
717pub const DEFAULT_REFRESH_MINUTES: u32 = 30;
719pub const MAX_SOURCE_BYTES: usize = 256 * 1024;
721
722pub 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 fn small_text(cfg: &Value, data: &Value) -> Value {
783 resolve(cfg, &ctx(0, "en", Some(data)))["small"]["content"].clone()
784 }
785
786 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 #[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 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 #[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 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 assert_eq!(texts(100), (json!("a"), json!("m")));
935 assert_eq!(upcoming_changes(&cfg, 100, 10), vec![200, 300]);
936 assert_eq!(texts(250), (json!("b"), json!("m2")));
938 assert_eq!(upcoming_changes(&cfg, 150, 10), vec![200, 300]);
939 assert_eq!(texts(300), (json!("c"), json!("m")));
941 assert_eq!(next_change_ms(&cfg, 300), None);
942 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 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 #[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 #[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 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 assert_eq!(ch[2]["content"], "{{index}}");
1061 assert_eq!(ch[3]["value"], 0.1, "missing bound value keeps the literal");
1062 assert_eq!(ch[4]["value"], json!(0));
1064 assert_eq!(ch[4]["label"], "{{oops}}");
1065 }
1066
1067 #[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 #[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 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 #[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 assert_eq!(MAX_SOURCE_BYTES, 262_144);
1204 }
1205}