Skip to main content

cards2pack_core/
routing.rs

1//! Build routing graph from card actions.
2
3use 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    /// node_id → outbound edges in declaration order.
16    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            // Cards emitted by the AC extension role compilers carry
37            // the route target under `data.nextCardId`; older fixtures
38            // and provider-side callbacks still use `data.routeToCardId`.
39            // Prefer the newer key, fall back to the legacy one — that
40            // way fresh designer output and pre-existing card sources
41            // both produce a non-empty routing graph.
42            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        // Cards emitted by the AC extension role compilers (and any
182        // hand-authored card that follows the AC v1.6 idiom) carry the
183        // route target under `data.nextCardId`. build_routing must
184        // resolve that key — without this it returns an empty graph
185        // and emit_ygtc writes routing: [] for every node.
186        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        // Defensive: if a card carries both keys (migration in flight,
204        // hand-merged JSON), nextCardId wins because that's the spec
205        // direction across AC ext + designer scaffold.
206        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        // Backward compat: existing fixtures and provider callbacks
227        // still carry `routeToCardId`. The fallback must keep them
228        // routing correctly.
229        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}