cards2pack_core/
routing.rs1use crate::errors::ConvertError;
4use crate::types::{CardEntry, CardKind, Diagnostic, DiagnosticKind};
5use std::collections::HashMap;
6
7#[derive(Debug, Clone, PartialEq)]
8pub struct RouteEdge {
9 pub action_id: String,
10 pub target: String,
11}
12
13#[derive(Debug, Default)]
14pub struct RoutingGraph {
15 pub edges: HashMap<String, Vec<RouteEdge>>,
17}
18
19pub fn build_routing(
20 cards: &[CardEntry],
21 strict: bool,
22) -> Result<(RoutingGraph, Vec<Diagnostic>), ConvertError> {
23 let known: std::collections::HashSet<&str> = cards.iter().map(|c| c.id.as_str()).collect();
24 let mut graph = RoutingGraph::default();
25 let mut diagnostics = Vec::new();
26
27 for card in cards {
28 let mut edges_for_card: Vec<RouteEdge> = Vec::new();
29 let CardKind::AdaptiveCard(json) = &card.kind else {
30 continue;
31 };
32 let actions = json.get("actions").and_then(|a| a.as_array());
33 let Some(actions) = actions else { continue };
34
35 for action in actions {
36 let target = action
37 .get("data")
38 .and_then(|d| d.get("routeToCardId"))
39 .and_then(|v| v.as_str())
40 .filter(|s| !s.is_empty());
41
42 let Some(target) = target else { continue };
43
44 if !known.contains(target) {
45 if strict {
46 return Err(ConvertError::DanglingRoute {
47 from: card.id.clone(),
48 to: target.into(),
49 });
50 }
51 diagnostics.push(Diagnostic {
52 kind: DiagnosticKind::DanglingRoute,
53 message: format!("card '{}' routes to unknown '{}'", card.id, target),
54 });
55 continue;
56 }
57
58 let action_id = action
59 .get("data")
60 .and_then(|d| d.get("action_id"))
61 .and_then(|v| v.as_str())
62 .map(str::to_owned)
63 .unwrap_or_else(|| format!("goto_{target}"));
64
65 edges_for_card.push(RouteEdge {
66 action_id,
67 target: target.to_owned(),
68 });
69 }
70
71 if !edges_for_card.is_empty() {
72 graph.edges.insert(card.id.clone(), edges_for_card);
73 }
74 }
75
76 Ok((graph, diagnostics))
77}
78
79#[cfg(test)]
80mod tests {
81 use super::*;
82 use crate::types::CardKind;
83 use serde_json::json;
84
85 fn card(id: &str, json: serde_json::Value) -> CardEntry {
86 CardEntry {
87 id: id.into(),
88 kind: CardKind::AdaptiveCard(json),
89 }
90 }
91
92 #[test]
93 fn edges_preserve_declaration_order() {
94 let cards = vec![
95 card(
96 "welcome",
97 json!({
98 "actions":[
99 {"type":"Action.Submit","data":{"routeToCardId":"a","action_id":"go_a"}},
100 {"type":"Action.Submit","data":{"routeToCardId":"b","action_id":"go_b"}}
101 ]
102 }),
103 ),
104 card("a", json!({})),
105 card("b", json!({})),
106 ];
107 let (g, diags) = build_routing(&cards, false).unwrap();
108 assert!(diags.is_empty());
109 let edges = g.edges.get("welcome").unwrap();
110 assert_eq!(edges[0].target, "a");
111 assert_eq!(edges[1].target, "b");
112 assert_eq!(edges[0].action_id, "go_a");
113 }
114
115 #[test]
116 fn back_edges_preserved() {
117 let cards = vec![
118 card(
119 "welcome",
120 json!({"actions":[{"type":"Action.Submit","data":{"routeToCardId":"chat"}}]}),
121 ),
122 card(
123 "chat",
124 json!({"actions":[{"type":"Action.Submit","data":{"routeToCardId":"welcome"}}]}),
125 ),
126 ];
127 let (g, _) = build_routing(&cards, false).unwrap();
128 let chat_edges = g.edges.get("chat").unwrap();
129 assert_eq!(chat_edges[0].target, "welcome");
130 }
131
132 #[test]
133 fn dangling_route_strict_errors() {
134 let cards = vec![card(
135 "welcome",
136 json!({"actions":[
137 {"type":"Action.Submit","data":{"routeToCardId":"missing"}}
138 ]}),
139 )];
140 let err = build_routing(&cards, true).unwrap_err();
141 assert_eq!(err.code(), "E_DANGLING_ROUTE");
142 }
143
144 #[test]
145 fn dangling_route_lenient_diagnostic() {
146 let cards = vec![card(
147 "welcome",
148 json!({"actions":[
149 {"type":"Action.Submit","data":{"routeToCardId":"missing"}}
150 ]}),
151 )];
152 let (g, diags) = build_routing(&cards, false).unwrap();
153 assert!(g.edges.is_empty());
154 assert_eq!(diags.len(), 1);
155 assert!(matches!(diags[0].kind, DiagnosticKind::DanglingRoute));
156 }
157
158 #[test]
159 fn synthesizes_action_id_when_missing() {
160 let cards = vec![
161 card(
162 "welcome",
163 json!({"actions":[
164 {"type":"Action.Submit","data":{"routeToCardId":"target"}}
165 ]}),
166 ),
167 card("target", json!({})),
168 ];
169 let (g, _) = build_routing(&cards, false).unwrap();
170 assert_eq!(g.edges.get("welcome").unwrap()[0].action_id, "goto_target");
171 }
172}