1use 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
43 .get("data")
44 .and_then(|d| d.get("nextCardId").or_else(|| d.get("routeToCardId")))
45 .and_then(|v| v.as_str())
46 .filter(|s| !s.is_empty());
47
48 let Some(target) = target else { continue };
49
50 if !known.contains(target) {
51 if strict {
52 return Err(ConvertError::DanglingRoute {
53 from: card.id.clone(),
54 to: target.into(),
55 });
56 }
57 diagnostics.push(Diagnostic {
58 kind: DiagnosticKind::DanglingRoute,
59 message: format!("card '{}' routes to unknown '{}'", card.id, target),
60 });
61 continue;
62 }
63
64 let action_id = action
65 .get("data")
66 .and_then(|d| d.get("action_id"))
67 .and_then(|v| v.as_str())
68 .map(str::to_owned)
69 .unwrap_or_else(|| format!("goto_{target}"));
70
71 edges_for_card.push(RouteEdge {
72 action_id,
73 target: target.to_owned(),
74 });
75 }
76
77 if !edges_for_card.is_empty() {
78 graph.edges.insert(card.id.clone(), edges_for_card);
79 }
80 }
81
82 Ok((graph, diagnostics))
83}
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88 use crate::types::CardKind;
89 use serde_json::json;
90
91 fn card(id: &str, json: serde_json::Value) -> CardEntry {
92 CardEntry {
93 id: id.into(),
94 kind: CardKind::AdaptiveCard(json),
95 }
96 }
97
98 #[test]
99 fn edges_preserve_declaration_order() {
100 let cards = vec![
101 card(
102 "welcome",
103 json!({
104 "actions":[
105 {"type":"Action.Submit","data":{"routeToCardId":"a","action_id":"go_a"}},
106 {"type":"Action.Submit","data":{"routeToCardId":"b","action_id":"go_b"}}
107 ]
108 }),
109 ),
110 card("a", json!({})),
111 card("b", json!({})),
112 ];
113 let (g, diags) = build_routing(&cards, false).unwrap();
114 assert!(diags.is_empty());
115 let edges = g.edges.get("welcome").unwrap();
116 assert_eq!(edges[0].target, "a");
117 assert_eq!(edges[1].target, "b");
118 assert_eq!(edges[0].action_id, "go_a");
119 }
120
121 #[test]
122 fn back_edges_preserved() {
123 let cards = vec![
124 card(
125 "welcome",
126 json!({"actions":[{"type":"Action.Submit","data":{"routeToCardId":"chat"}}]}),
127 ),
128 card(
129 "chat",
130 json!({"actions":[{"type":"Action.Submit","data":{"routeToCardId":"welcome"}}]}),
131 ),
132 ];
133 let (g, _) = build_routing(&cards, false).unwrap();
134 let chat_edges = g.edges.get("chat").unwrap();
135 assert_eq!(chat_edges[0].target, "welcome");
136 }
137
138 #[test]
139 fn dangling_route_strict_errors() {
140 let cards = vec![card(
141 "welcome",
142 json!({"actions":[
143 {"type":"Action.Submit","data":{"routeToCardId":"missing"}}
144 ]}),
145 )];
146 let err = build_routing(&cards, true).unwrap_err();
147 assert_eq!(err.code(), "E_DANGLING_ROUTE");
148 }
149
150 #[test]
151 fn dangling_route_lenient_diagnostic() {
152 let cards = vec![card(
153 "welcome",
154 json!({"actions":[
155 {"type":"Action.Submit","data":{"routeToCardId":"missing"}}
156 ]}),
157 )];
158 let (g, diags) = build_routing(&cards, false).unwrap();
159 assert!(g.edges.is_empty());
160 assert_eq!(diags.len(), 1);
161 assert!(matches!(diags[0].kind, DiagnosticKind::DanglingRoute));
162 }
163
164 #[test]
165 fn synthesizes_action_id_when_missing() {
166 let cards = vec![
167 card(
168 "welcome",
169 json!({"actions":[
170 {"type":"Action.Submit","data":{"routeToCardId":"target"}}
171 ]}),
172 ),
173 card("target", json!({})),
174 ];
175 let (g, _) = build_routing(&cards, false).unwrap();
176 assert_eq!(g.edges.get("welcome").unwrap()[0].action_id, "goto_target");
177 }
178
179 #[test]
180 fn routes_via_next_card_id() {
181 let cards = vec![
187 card(
188 "welcome",
189 json!({"actions":[
190 {"type":"Action.Submit","data":{"nextCardId":"customer_size"}}
191 ]}),
192 ),
193 card("customer_size", json!({})),
194 ];
195 let (g, diags) = build_routing(&cards, false).unwrap();
196 assert!(diags.is_empty(), "got: {diags:?}");
197 let edges = g.edges.get("welcome").unwrap();
198 assert_eq!(edges[0].target, "customer_size");
199 }
200
201 #[test]
202 fn prefers_next_card_id_over_route_to_card_id() {
203 let cards = vec![
207 card(
208 "welcome",
209 json!({"actions":[
210 {"type":"Action.Submit","data":{
211 "nextCardId":"new",
212 "routeToCardId":"legacy"
213 }}
214 ]}),
215 ),
216 card("new", json!({})),
217 card("legacy", json!({})),
218 ];
219 let (g, _) = build_routing(&cards, false).unwrap();
220 let edges = g.edges.get("welcome").unwrap();
221 assert_eq!(edges[0].target, "new");
222 }
223
224 #[test]
225 fn falls_back_to_route_to_card_id_when_next_card_id_absent() {
226 let cards = vec![
230 card(
231 "welcome",
232 json!({"actions":[
233 {"type":"Action.Submit","data":{"routeToCardId":"legacy_target"}}
234 ]}),
235 ),
236 card("legacy_target", json!({})),
237 ];
238 let (g, diags) = build_routing(&cards, false).unwrap();
239 assert!(diags.is_empty(), "got: {diags:?}");
240 let edges = g.edges.get("welcome").unwrap();
241 assert_eq!(edges[0].target, "legacy_target");
242 }
243}