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            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}