1use crate::models::{
8 ColorValue, ElementStyle, FontWeight, TextAlignment, WidgetConfig, WidgetElement,
9 VStackElement, HStackElement, ZStackElement, GridElement, ContainerElement, TextElement, ImageElement, ProgressElement, GaugeElement, ButtonElement, ToggleElement, DividerElement, DateElement, ChartElement, ListElement, LinkElement, ShapeElement, TimerElement, CanvasElement, LabelElement,
10};
11use crate::receipt::SkippedElement;
12use serde_json::{json, Value};
13
14#[derive(Debug, Clone)]
16pub struct TranspileResult {
17 pub card: Value,
18 pub skipped: Vec<SkippedElement>,
19}
20
21pub fn to_adaptive_card(root: &WidgetElement) -> TranspileResult {
23 let mut skipped = Vec::new();
24 let body = el(root, &mut skipped);
25 let card = json!({
26 "type": "AdaptiveCard",
27 "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
28 "version": "1.5",
29 "body": [body],
30 });
31 TranspileResult { card, skipped }
32}
33
34pub fn to_adaptive_card_for_size(config: &WidgetConfig, size: &str) -> Option<TranspileResult> {
36 let root = match size {
37 "large" => config
38 .large
39 .as_ref()
40 .or(config.medium.as_ref())
41 .or(config.small.as_ref()),
42 "medium" => config
43 .medium
44 .as_ref()
45 .or(config.large.as_ref())
46 .or(config.small.as_ref()),
47 _ => config
48 .small
49 .as_ref()
50 .or(config.medium.as_ref())
51 .or(config.large.as_ref()),
52 }?;
53 Some(to_adaptive_card(root))
54}
55
56pub fn validate_card_structure(card: &Value) -> Result<(), String> {
58 let obj = card
59 .as_object()
60 .ok_or_else(|| "card must be an object".to_string())?;
61 if obj.get("type").and_then(|v| v.as_str()) != Some("AdaptiveCard") {
62 return Err("type must be AdaptiveCard".into());
63 }
64 if obj.get("version").and_then(|v| v.as_str()) != Some("1.5") {
65 return Err("version must be 1.5".into());
66 }
67 match obj.get("body") {
68 Some(Value::Array(_)) => Ok(()),
69 _ => Err("body must be an array".into()),
70 }
71}
72
73pub fn ac_template_key(widget_id: &str) -> String {
75 format!("ac:template:{widget_id}")
76}
77
78pub fn ac_data_key(widget_id: &str) -> String {
79 format!("ac:data:{widget_id}")
80}
81
82fn el(e: &WidgetElement, skipped: &mut Vec<SkippedElement>) -> Value {
83 match e {
84 WidgetElement::VStack(VStackElement {
85 children,
86 style,
87 alignment,
88 spacing,
89 ..
90 }) => {
91 let mut obj = json!({
92 "type": "Container",
93 "spacing": "None",
94 "items": children.iter().map(|c| el(c, skipped)).collect::<Vec<_>>(),
95 });
96 apply_container_style(&mut obj, style);
97 apply_gap_id(&mut obj, *spacing);
98 if let Some(a) = alignment {
100 match a {
101 crate::models::HorizontalAlignment::Center => {
102 obj["horizontalAlignment"] = json!("Center");
103 }
104 crate::models::HorizontalAlignment::Trailing => {
105 obj["horizontalAlignment"] = json!("Right");
106 }
107 _ => {}
108 }
109 }
110 obj
111 }
112
113 WidgetElement::HStack(HStackElement {
114 children, spacing, ..
115 }) => {
116 let n = children.len();
117 let mut obj = json!({
118 "type": "ColumnSet",
119 "columns": children
120 .iter()
121 .enumerate()
122 .map(|(i, c)| {
123 let (width, items) = match c {
124 WidgetElement::Spacer(_) => (json!("stretch"), json!([])),
125 _ if i + 1 < n
127 && matches!(children[i + 1], WidgetElement::Spacer(_)) =>
128 {
129 (json!("stretch"), json!([el(c, skipped)]))
130 }
131 _ => (flex_width(c), json!([el(c, skipped)])),
132 };
133 json!({
134 "type": "Column",
135 "width": width,
136 "items": items,
137 })
138 })
139 .collect::<Vec<_>>(),
140 });
141 apply_gap_id(&mut obj, *spacing);
142 obj
143 }
144
145 WidgetElement::Container(ContainerElement {
146 children,
147 style,
148 content_alignment,
149 ..
150 }) => {
151 let mut obj = json!({
152 "type": "Container",
153 "spacing": "None",
154 "items": children.iter().map(|c| el(c, skipped)).collect::<Vec<_>>(),
155 });
156 apply_container_style(&mut obj, style);
157 if content_alignment
158 .as_deref()
159 .map(|a| a.to_ascii_lowercase().contains("center"))
160 .unwrap_or(false)
161 {
162 obj["horizontalAlignment"] = json!("Center");
163 }
164 obj
165 }
166
167 WidgetElement::Grid(GridElement {
168 children,
169 columns,
170 spacing,
171 row_spacing,
172 ..
173 }) => {
174 let cols = (*columns).max(1) as usize;
175 let mut rows = Vec::new();
176 for chunk in children.chunks(cols) {
177 let columns_json: Vec<Value> = chunk
178 .iter()
179 .map(|c| {
180 json!({
181 "type": "Column",
182 "width": "stretch",
183 "items": [el(c, skipped)],
184 })
185 })
186 .collect();
187 let mut row = json!({
188 "type": "ColumnSet",
189 "columns": columns_json,
190 });
191 apply_gap_id(&mut row, *spacing);
192 rows.push(row);
193 }
194 let mut obj = json!({
195 "type": "Container",
196 "spacing": "None",
197 "items": rows,
198 });
199 apply_gap_id(&mut obj, row_spacing.or(*spacing));
200 obj
201 }
202
203 WidgetElement::Text(TextElement {
204 content,
205 font_size,
206 font_weight,
207 color,
208 alignment,
209 line_limit,
210 ..
211 }) => {
212 let wrap = !matches!(line_limit, Some(n) if *n <= 1);
213 let mut obj = json!({
214 "type": "TextBlock",
215 "text": content,
216 "wrap": wrap,
217 "size": size_bucket(*font_size),
218 "weight": weight_token(font_weight.as_ref()),
219 });
220 if let Some(c) =
221 ac_color(color.as_ref()).or_else(|| approx_hex_semantic(color.as_ref()))
222 {
223 obj["color"] = json!(c);
224 }
225 if let Some(hex) = color_hex(color.as_ref()) {
227 obj["id"] = json!(format!("fg:{hex}"));
228 }
229 if let Some(a) = align(alignment.as_ref()) {
230 obj["horizontalAlignment"] = json!(a);
231 }
232 obj
233 }
234
235 WidgetElement::Image(ImageElement {
236 url,
237 data,
238 system_name,
239 size,
240 color,
241 ..
242 }) => {
243 let url_str = url.clone().unwrap_or_else(|| {
244 let b64 = data.clone().unwrap_or_default();
245 if b64.is_empty() {
246 String::new()
247 } else if b64.starts_with("data:") {
248 b64
249 } else {
250 format!("data:image/png;base64,{b64}")
251 }
252 });
253 if url_str.is_empty() {
254 let glyph = system_name.as_deref().map(sf_symbol_glyph).unwrap_or("•");
256 let mut obj = json!({
257 "type": "TextBlock",
258 "text": glyph,
259 "wrap": true,
260 "size": size_bucket(size.map(|s| s * 0.75)),
261 "weight": "Bolder",
262 });
263 if let Some(c) =
264 ac_color(color.as_ref()).or_else(|| approx_hex_semantic(color.as_ref()))
265 {
266 obj["color"] = json!(c);
267 }
268 return obj;
269 }
270 json!({
271 "type": "Image",
272 "url": url_str,
273 "size": img_size_bucket(*size),
274 })
275 }
276
277 WidgetElement::Button(ButtonElement {
278 label,
279 action,
280 url,
281 background_color,
282 color,
283 ..
284 }) => {
285 let title = sanitize_button_label(label);
286 let mut action_json = match action {
287 Some(a) => json!({
288 "type": "Action.Execute",
289 "title": title,
290 "verb": a,
291 }),
292 None => json!({
293 "type": "Action.OpenUrl",
294 "title": title,
295 "url": url.clone().unwrap_or_default(),
296 }),
297 };
298 let mut id_parts = Vec::new();
300 if let Some(hex) = color_hex(background_color.as_ref()) {
301 id_parts.push(format!("bg:{hex}"));
302 }
303 if let Some(hex) = color_hex(color.as_ref()) {
304 id_parts.push(format!("fg:{hex}"));
305 }
306 if !id_parts.is_empty() {
307 action_json["id"] = json!(id_parts.join(";"));
308 }
309 json!({
310 "type": "ActionSet",
311 "actions": [action_json],
312 })
313 }
314
315 WidgetElement::Toggle(ToggleElement {
316 is_on,
317 label,
318 action,
319 ..
320 }) => {
321 let title =
323 label
324 .clone()
325 .unwrap_or_else(|| if *is_on { "On".into() } else { "Off".into() });
326 let mark = if *is_on { "✓" } else { "○" };
327 let mut block = json!({
328 "type": "TextBlock",
329 "text": format!("{mark} {title}"),
330 "wrap": true,
331 "size": "Default",
332 });
333 if let Some(a) = action {
334 return json!({
337 "type": "Container",
338 "items": [block],
339 "selectAction": {
340 "type": "Action.Execute",
341 "verb": a,
342 "data": { "payload": (!is_on).to_string() },
343 },
344 });
345 }
346 let _ = &mut block;
347 block
348 }
349
350 WidgetElement::Progress(ProgressElement {
351 value,
352 total,
353 label,
354 tint,
355 color,
356 ..
357 }) => {
358 let total = if *total <= 0.0 { 1.0 } else { *total };
359 let pct = ((*value / total) * 100.0).clamp(0.0, 100.0) as u32;
360 let rest = 100u32.saturating_sub(pct);
361 let mut items = Vec::new();
362 if let Some(l) = label {
363 let mut tb = json!({
364 "type": "TextBlock",
365 "text": l,
366 "size": "Small",
367 "wrap": true,
368 });
369 if let Some(c) = ac_color(color.as_ref())
370 .or_else(|| ac_color(tint.as_ref()))
371 .or_else(|| approx_hex_semantic(color.as_ref()))
372 .or_else(|| approx_hex_semantic(tint.as_ref()))
373 {
374 tb["color"] = json!(c);
375 }
376 items.push(tb);
377 }
378 let fill_hex = color_hex(tint.as_ref());
379 let fill_style = approx_hex_semantic(tint.as_ref())
380 .unwrap_or("Good")
381 .to_ascii_lowercase();
382 let mut filled = json!({
383 "type": "Column",
384 "width": pct.max(1),
385 "style": fill_style,
386 "items": []
387 });
388 if let Some(ref hex) = fill_hex {
389 filled["id"] = json!(format!("fill:{hex}"));
390 }
391 items.push(json!({
392 "type": "ColumnSet",
393 "columns": [
394 filled,
395 {
396 "type": "Column",
397 "width": rest.max(1),
398 "items": []
399 }
400 ],
401 }));
402 json!({
403 "type": "Container",
404 "items": items,
405 })
406 }
407
408 WidgetElement::Divider(DividerElement { color, .. }) => {
409 let mut obj = json!({
410 "type": "Container",
411 "separator": true,
412 "spacing": "Medium",
413 "items": [],
414 });
415 if let Some(hex) = color_hex(color.as_ref()) {
416 obj["id"] = json!(format!("rule:{hex}"));
417 } else {
418 obj["id"] = json!("rule:#334155");
419 }
420 obj
421 }
422
423 WidgetElement::Spacer(_) => json!({
424 "type": "TextBlock",
425 "text": " ",
426 "spacing": "Medium",
427 }),
428
429 WidgetElement::Date(DateElement { date, .. }) => json!({
430 "type": "TextBlock",
431 "text": date,
432 "wrap": true,
433 "size": "Default",
434 }),
435
436 WidgetElement::Timer(TimerElement { target_date, .. }) => json!({
437 "type": "TextBlock",
438 "text": target_date,
439 "wrap": true,
440 "size": "Default",
441 }),
442
443 WidgetElement::Label(LabelElement {
444 text,
445 system_name,
446 color,
447 ..
448 }) => {
449 let prefix = if system_name.is_empty() {
450 String::new()
451 } else {
452 format!("{} ", sf_symbol_glyph(system_name))
453 };
454 let mut obj = json!({
455 "type": "TextBlock",
456 "text": format!("{prefix}{text}"),
457 "wrap": true,
458 "weight": "Bolder",
459 "size": "Small",
460 });
461 if let Some(c) =
462 ac_color(color.as_ref()).or_else(|| approx_hex_semantic(color.as_ref()))
463 {
464 obj["color"] = json!(c);
465 } else {
466 obj["color"] = json!("Light");
467 }
468 obj
469 }
470
471 WidgetElement::Link(LinkElement {
472 children,
473 action,
474 url,
475 style,
476 ..
477 }) => {
478 let inner: Vec<Value> = children.iter().map(|c| el(c, skipped)).collect();
479 let mut container = json!({
480 "type": "Container",
481 "items": inner,
482 "selectAction": match action {
483 Some(a) => json!({ "type": "Action.Execute", "verb": a }),
484 None => json!({
485 "type": "Action.OpenUrl",
486 "url": url.clone().unwrap_or_default(),
487 }),
488 },
489 });
490 apply_container_style(&mut container, style);
491 container
492 }
493
494 WidgetElement::List(ListElement { items, spacing, .. }) => {
495 let row_gap = spacing.or(Some(4.0));
497 let lines: Vec<Value> = items
498 .iter()
499 .enumerate()
500 .map(|(idx, it)| {
501 let (mark, mark_color) = match it.checked {
502 Some(true) => ("✓", "Good"),
503 Some(false) => ("○", "Light"),
504 None => (" ", "Light"),
506 };
507 let row = json!({
508 "type": "ColumnSet",
509 "columns": [
510 {
511 "type": "Column",
512 "width": "auto",
513 "id": "mark:col",
514 "items": [{
515 "type": "TextBlock",
516 "text": mark,
517 "wrap": false,
518 "size": "Small",
519 "color": mark_color,
520 "horizontalAlignment": "Center",
521 }]
522 },
523 {
524 "type": "Column",
525 "width": "stretch",
526 "items": [{
527 "type": "TextBlock",
528 "text": it.text,
529 "wrap": true,
530 "size": "Small",
531 "horizontalAlignment": "Left",
532 "color": "Light",
533 }]
534 }
535 ]
536 });
537 let mut item = if let Some(ref a) = it.action {
539 json!({
540 "type": "Container",
541 "items": [row],
542 "selectAction": {
543 "type": "Action.Execute",
544 "verb": a,
545 "data": { "payload": it.payload },
546 }
547 })
548 } else {
549 row
550 };
551 if idx > 0 {
553 apply_gap_id(&mut item, row_gap);
554 }
555 item
556 })
557 .collect();
558 json!({
559 "type": "Container",
560 "horizontalAlignment": "Left",
561 "spacing": "None",
562 "items": lines,
563 })
564 }
565
566 WidgetElement::Shape(_) => rasterized_or_skip(e, skipped),
567
568 WidgetElement::ZStack(ZStackElement {
569 children,
570 style,
571 alignment,
572 ..
573 }) => {
574 let mut obj = json!({
576 "type": "Container",
577 "spacing": "None",
578 "items": children.iter().map(|c| el(c, skipped)).collect::<Vec<_>>(),
579 });
580 apply_container_style(&mut obj, style);
581 let centered = alignment
582 .as_deref()
583 .map(|a| a.to_ascii_lowercase().contains("center"))
584 .unwrap_or(false);
585 if centered {
586 obj["horizontalAlignment"] = json!("Center");
587 }
588 if let Some(items) = obj.get("items").and_then(|i| i.as_array()) {
590 if items.len() == 2 {
591 obj["id"] = json!("badge:overlay");
592 }
593 }
594 obj
595 }
596
597 WidgetElement::Gauge(_)
598 | WidgetElement::Chart(_)
599 | WidgetElement::Canvas(_) => rasterized_or_skip(e, skipped),
600 }
601}
602
603fn rasterized_or_skip(e: &WidgetElement, skipped: &mut Vec<SkippedElement>) -> Value {
604 let ty = e.type_name();
605 match crate::rasterize::element_to_png_data_uri(e) {
606 Ok(uri) => {
607 let mut img = json!({
608 "type": "Image",
609 "url": uri,
610 "size": "Medium",
611 "horizontalAlignment": "Center",
612 });
613 match e {
614 WidgetElement::Canvas(_)
615 | WidgetElement::Chart(_)
616 | WidgetElement::Gauge(_) => {
617 img["size"] = json!("Stretch");
618 }
619 WidgetElement::Shape(ShapeElement { size, .. }) => {
620 img["size"] = json!(img_size_bucket(*size));
621 }
622 _ => {}
623 }
624 img
625 }
626 Err(err) => {
627 skipped.push(SkippedElement {
628 type_name: ty.into(),
629 reason: format!("rasterize failed: {err}"),
630 });
631 json!({
632 "type": "TextBlock",
633 "text": "",
634 "spacing": "None",
635 })
636 }
637 }
638}
639
640fn flex_width(c: &WidgetElement) -> Value {
641 flex_of(c)
642 .map(|f| {
643 if f <= 0.0 {
644 json!("auto")
645 } else {
646 json!((f * 100.0).round() as u64)
647 }
648 })
649 .unwrap_or(json!("auto"))
650}
651
652fn flex_of(e: &WidgetElement) -> Option<f64> {
653 match e {
654 WidgetElement::VStack(VStackElement { style, .. })
655 | WidgetElement::HStack(HStackElement { style, .. })
656 | WidgetElement::ZStack(ZStackElement { style, .. })
657 | WidgetElement::Grid(GridElement { style, .. })
658 | WidgetElement::Container(ContainerElement { style, .. })
659 | WidgetElement::Text(TextElement { style, .. })
660 | WidgetElement::Image(ImageElement { style, .. })
661 | WidgetElement::Progress(ProgressElement { style, .. })
662 | WidgetElement::Gauge(GaugeElement { style, .. })
663 | WidgetElement::Button(ButtonElement { style, .. })
664 | WidgetElement::Toggle(ToggleElement { style, .. })
665 | WidgetElement::Divider(DividerElement { style, .. })
666 | WidgetElement::Date(DateElement { style, .. })
667 | WidgetElement::Chart(ChartElement { style, .. })
668 | WidgetElement::List(ListElement { style, .. })
669 | WidgetElement::Link(LinkElement { style, .. })
670 | WidgetElement::Shape(ShapeElement { style, .. })
671 | WidgetElement::Timer(TimerElement { style, .. })
672 | WidgetElement::Canvas(CanvasElement { style, .. })
673 | WidgetElement::Label(LabelElement { style, .. }) => style.flex,
674 WidgetElement::Spacer(_) => None,
675 }
676}
677
678fn push_id_token(obj: &mut Value, token: &str) {
679 match obj.get("id").and_then(|v| v.as_str()) {
680 Some(id) if !id.is_empty() => {
681 if id.split(';').any(|p| p == token) {
682 return;
683 }
684 obj["id"] = json!(format!("{id};{token}"));
685 }
686 _ => {
687 obj["id"] = json!(token);
688 }
689 }
690}
691
692fn apply_gap_id(obj: &mut Value, spacing: Option<f64>) {
693 let Some(s) = spacing else {
694 return;
695 };
696 if s <= 0.0 {
697 return;
698 }
699 let ac = if s < 4.0 {
701 "Small"
702 } else if s < 10.0 {
703 "Default"
704 } else if s < 16.0 {
705 "Medium"
706 } else {
707 "Large"
708 };
709 obj["spacing"] = json!(ac);
710 push_id_token(obj, &format!("gap:{}", s.round() as i64));
711}
712
713fn apply_container_style(obj: &mut Value, style: &ElementStyle) {
714 if let Some(bg) = &style.background {
715 obj["style"] = json!("emphasis");
717 if let Some(hex) = background_hex(bg) {
718 push_id_token(obj, &format!("card:{hex}"));
719 }
720 }
721}
722
723fn background_hex(bg: &crate::models::BackgroundValue) -> Option<String> {
724 match bg {
725 crate::models::BackgroundValue::Solid(s) => {
726 let s = s.trim();
727 if s.starts_with('#') {
728 Some(s.to_ascii_lowercase())
729 } else {
730 None
731 }
732 }
733 crate::models::BackgroundValue::Adaptive { dark, .. } => {
734 let s = dark.trim();
735 if s.starts_with('#') {
736 Some(s.to_ascii_lowercase())
737 } else {
738 None
739 }
740 }
741 crate::models::BackgroundValue::Gradient(g) => g
742 .colors
743 .first()
744 .map(|c| c.trim().to_ascii_lowercase())
745 .filter(|c| c.starts_with('#')),
746 }
747}
748
749fn size_bucket(font_size: Option<f64>) -> &'static str {
750 match font_size {
751 Some(s) if s <= 12.0 => "Small",
752 Some(s) if s <= 16.0 => "Default",
753 Some(s) if s <= 22.0 => "Medium",
754 Some(s) if s <= 32.0 => "Large",
755 Some(_) => "ExtraLarge",
756 None => "Default",
757 }
758}
759
760fn img_size_bucket(size: Option<f64>) -> &'static str {
761 match size {
762 Some(s) if s <= 24.0 => "Small",
763 Some(s) if s <= 48.0 => "Medium",
764 Some(_) => "Large",
765 None => "Medium",
766 }
767}
768
769fn weight_token(w: Option<&FontWeight>) -> &'static str {
770 match w {
771 Some(FontWeight::Bold)
772 | Some(FontWeight::Semibold)
773 | Some(FontWeight::Heavy)
774 | Some(FontWeight::Black) => "Bolder",
775 Some(FontWeight::Light) | Some(FontWeight::Thin) | Some(FontWeight::Ultralight) => {
776 "Lighter"
777 }
778 _ => "Default",
779 }
780}
781
782fn align(a: Option<&TextAlignment>) -> Option<&'static str> {
783 match a {
784 Some(TextAlignment::Leading) => Some("Left"),
785 Some(TextAlignment::Center) => Some("Center"),
786 Some(TextAlignment::Trailing) => Some("Right"),
787 None => None,
788 }
789}
790
791fn ac_color(color: Option<&ColorValue>) -> Option<&'static str> {
793 let raw = match color? {
794 ColorValue::Solid(s) => s.as_str(),
795 ColorValue::Adaptive { light, .. } => light.as_str(),
796 };
797 let lower = raw.to_ascii_lowercase();
798 match lower.as_str() {
799 "accent" => Some("Accent"),
800 "good" | "success" => Some("Good"),
801 "warning" => Some("Warning"),
802 "attention" | "error" | "danger" => Some("Attention"),
803 "label" | "dark" => Some("Default"),
805 "secondarylabel" | "light" => Some("Light"),
806 "default" => Some("Default"),
807 s if s.starts_with('#') => None,
808 _ => None,
809 }
810}
811
812fn color_hex(color: Option<&ColorValue>) -> Option<String> {
813 let raw = match color? {
814 ColorValue::Solid(s) => s.as_str(),
815 ColorValue::Adaptive { light, .. } => light.as_str(),
816 };
817 let s = raw.trim();
818 if s.starts_with('#') && (s.len() == 7 || s.len() == 4) {
819 Some(s.to_ascii_lowercase())
820 } else {
821 None
822 }
823}
824
825fn approx_hex_semantic(color: Option<&ColorValue>) -> Option<&'static str> {
827 let hex = color_hex(color)?;
828 let (r, g, b) = parse_hex_rgb(&hex)?;
829 let max = r.max(g).max(b) as f32;
830 let min = r.min(g).min(b) as f32;
831 let luma = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32;
832 let sat = max - min;
833 if luma < 60.0 {
835 return Some("Light");
836 }
837 if sat < 50.0 {
839 return Some("Light");
840 }
841 if luma > 200.0 && sat < 30.0 {
842 return Some("Light");
843 }
844 let (rf, gf, bf) = (r as f32, g as f32, b as f32);
845 if rf > gf + 30.0 && rf > bf + 30.0 {
846 return Some("Attention");
847 }
848 if gf > rf + 20.0 && gf > bf + 20.0 {
849 return Some("Good");
850 }
851 if bf > rf + 20.0 && bf > gf + 10.0 {
852 return Some("Accent");
853 }
854 if rf > 180.0 && gf > 120.0 && bf < 100.0 {
855 return Some("Warning");
856 }
857 Some("Default")
858}
859
860fn parse_hex_rgb(hex: &str) -> Option<(u8, u8, u8)> {
861 let h = hex.trim_start_matches('#');
862 if h.len() == 3 {
863 let r = u8::from_str_radix(&h[0..1].repeat(2), 16).ok()?;
864 let g = u8::from_str_radix(&h[1..2].repeat(2), 16).ok()?;
865 let b = u8::from_str_radix(&h[2..3].repeat(2), 16).ok()?;
866 return Some((r, g, b));
867 }
868 if h.len() == 6 {
869 let r = u8::from_str_radix(&h[0..2], 16).ok()?;
870 let g = u8::from_str_radix(&h[2..4], 16).ok()?;
871 let b = u8::from_str_radix(&h[4..6], 16).ok()?;
872 return Some((r, g, b));
873 }
874 None
875}
876
877fn sanitize_button_label(label: &str) -> String {
879 let t = label.trim();
880 match t {
881 "⏮" | "⏮️" => "Prev".into(),
882 "⏭" | "⏭️" => "Next".into(),
883 "⏸" | "⏸️" => "Pause".into(),
884 "▶" | "▶️" | "⏯" => "Play".into(),
885 "⏹" | "⏹️" => "Stop".into(),
886 "⌫" => "BS".into(),
887 "±" => "+/-".into(),
888 "÷" => "/".into(),
889 "×" => "x".into(),
890 "−" => "-".into(),
891 _ => {
892 let mut s = t.to_string();
893 for (from, to) in [
894 ("⏮", "Prev"),
895 ("⏭", "Next"),
896 ("⏸", "Pause"),
897 ("▶", "Play"),
898 ("⌫", "BS"),
899 ("±", "+/-"),
900 ("÷", "/"),
901 ("×", "x"),
902 ("−", "-"),
903 ] {
904 if s.contains(from) {
905 s = s.replace(from, to);
906 }
907 }
908 s
909 }
910 }
911}
912
913fn sf_symbol_glyph(name: &str) -> &'static str {
914 let n = name.to_ascii_lowercase();
915 match n.as_str() {
916 "gear" | "gearshape" | "gearshape.fill" => "*",
917 "person" | "person.fill" => "☺",
918 "checkmark" | "checkmark.circle" | "checkmark.circle.fill" => "✓",
919 "calendar" | "calendar.badge.clock" | "calendar.circle" => "◷",
920 "xmark" | "xmark.circle" => "x",
921 "star" | "star.fill" => "*",
922 "heart" | "heart.fill" => "+",
923 "bell" | "bell.fill" => "!",
924 "house" | "house.fill" => "H",
925 "magnifyingglass" => "?",
926 "plus" | "plus.circle" => "+",
927 "minus" | "minus.circle" => "-",
928 _ => "•",
929 }
930}
931
932#[cfg(test)]
933mod tests {
934 use super::*;
935 use std::fs;
936 use std::path::PathBuf;
937
938 fn fixtures_dir() -> PathBuf {
939 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
940 }
941
942 fn snapshots_dir() -> PathBuf {
943 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots/adaptive")
944 }
945
946 fn load_config(rel: &str) -> WidgetConfig {
947 let path = fixtures_dir().join(rel);
948 let raw =
949 fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
950 let v: Value = serde_json::from_str(&raw).expect("json");
952 serde_json::from_value(v).unwrap_or_else(|e| panic!("parse {rel}: {e}"))
953 }
954
955 #[test]
956 fn weather_small_is_valid_v15() {
957 let cfg = load_config("presets/weather.json");
958 let result = to_adaptive_card_for_size(&cfg, "small").expect("small layout");
959 validate_card_structure(&result.card).unwrap();
960 assert_eq!(result.card["body"].as_array().unwrap().len(), 1);
961 assert_eq!(result.card["body"][0]["type"], "Container");
962 }
963
964 #[test]
965 fn unsupported_nodes_are_skipped() {
966 let cfg: WidgetConfig = serde_json::from_value(json!({
967 "version": 1,
968 "small": {
969 "type": "vstack",
970 "children": [
971 { "type": "text", "content": "hi" },
972 { "type": "canvas", "width": 10, "height": 10, "elements": [] },
973 { "type": "zstack", "children": [
974 { "type": "text", "content": "a" }
975 ] }
976 ]
977 }
978 }))
979 .unwrap();
980 let result = to_adaptive_card_for_size(&cfg, "small").unwrap();
981 let body = serde_json::to_string(&result.card["body"]).unwrap();
983 assert!(body.contains("Container") || body.contains("Image") || body.contains("TextBlock"));
984 validate_card_structure(&result.card).unwrap();
985 }
986
987 #[test]
988 #[cfg(feature = "rasterize")]
989 fn chart_becomes_image_data_uri() {
990 let cfg: WidgetConfig = serde_json::from_value(json!({
991 "version": 1,
992 "small": {
993 "type": "chart",
994 "chartType": "bar",
995 "chartData": [
996 { "label": "a", "value": 1 },
997 { "label": "b", "value": 2 }
998 ]
999 }
1000 }))
1001 .unwrap();
1002 let result = to_adaptive_card_for_size(&cfg, "small").unwrap();
1003 assert!(result.skipped.is_empty(), "{:?}", result.skipped);
1004 assert_eq!(result.card["body"][0]["type"], "Image");
1005 let url = result.card["body"][0]["url"].as_str().unwrap();
1006 assert!(url.starts_with("data:image/png;base64,"));
1007 }
1008
1009 #[test]
1010 fn weather_small_matches_golden_or_writes_hint() {
1011 let cfg = load_config("presets/weather.json");
1012 let result = to_adaptive_card_for_size(&cfg, "small").unwrap();
1013 let golden_path = snapshots_dir().join("weather.small.json");
1014 if !golden_path.exists() {
1015 fs::create_dir_all(snapshots_dir()).unwrap();
1016 let pretty = serde_json::to_string_pretty(&result.card).unwrap();
1017 fs::write(&golden_path, pretty).unwrap();
1018 validate_card_structure(&result.card).unwrap();
1020 return;
1021 }
1022 let expected: Value =
1023 serde_json::from_str(&fs::read_to_string(&golden_path).unwrap()).unwrap();
1024 assert_eq!(
1025 result.card, expected,
1026 "adaptive card drift — update tests/snapshots/adaptive/weather.small.json if intentional"
1027 );
1028 }
1029
1030 #[test]
1031 fn cases_transpile_without_panic() {
1032 let cases_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/cases");
1033 let Ok(entries) = fs::read_dir(&cases_dir) else {
1034 return;
1035 };
1036 for entry in entries.flatten() {
1037 let path = entry.path();
1038 if path.extension().and_then(|e| e.to_str()) != Some("json") {
1039 continue;
1040 }
1041 let case: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1042 let fixture = case["fixture"].as_str().unwrap_or("");
1043 let size = case["size"].as_str().unwrap_or("small");
1044 let cfg_path = fixtures_dir().join(format!("{fixture}.json"));
1045 if !cfg_path.exists() {
1046 continue;
1047 }
1048 let cfg = load_config(&format!("{fixture}.json"));
1049 let Some(result) = to_adaptive_card_for_size(&cfg, size) else {
1050 continue;
1051 };
1052 validate_card_structure(&result.card)
1053 .unwrap_or_else(|e| panic!("{}: {e}", path.display()));
1054 }
1055 }
1056}