Skip to main content

cards2pack_core/
convert.rs

1//! Public convert() orchestrator.
2
3use crate::emit::emit_ygtc;
4use crate::entry::detect_entry;
5use crate::errors::ConvertError;
6use crate::http_inject::inject_http_nodes;
7use crate::routing::build_routing;
8use crate::types::{CardEntry, ConvertOptions, ConvertResult};
9
10pub fn convert(cards: &[CardEntry], opts: &ConvertOptions) -> Result<ConvertResult, ConvertError> {
11    if cards.is_empty() {
12        return Err(ConvertError::NoCards);
13    }
14
15    let entry = detect_entry(cards)?;
16    let (mut routing, mut diagnostics) = build_routing(cards, opts.strict)?;
17    let http_nodes = inject_http_nodes(cards, &mut routing);
18
19    // Reachability diagnostic (lenient): warn on cards never targeted (unless they ARE the entry).
20    let mut reachable: std::collections::HashSet<&str> = std::collections::HashSet::new();
21    reachable.insert(entry.as_str());
22    let mut frontier = vec![entry.as_str()];
23    while let Some(node) = frontier.pop() {
24        if let Some(edges) = routing.edges.get(node) {
25            for e in edges {
26                if reachable.insert(e.target.as_str()) {
27                    frontier.push(e.target.as_str());
28                }
29            }
30        }
31    }
32    for card in cards {
33        if !reachable.contains(card.id.as_str()) {
34            diagnostics.push(crate::types::Diagnostic {
35                kind: crate::types::DiagnosticKind::UnreachableCard,
36                message: format!("card '{}' is unreachable from entry '{}'", card.id, entry),
37            });
38        }
39    }
40
41    let flow_yaml = emit_ygtc(cards, &entry, &routing, &http_nodes, &opts.flow_name)?;
42
43    Ok(ConvertResult {
44        flow_yaml,
45        diagnostics,
46    })
47}
48
49#[cfg(test)]
50mod tests {
51    use super::*;
52    use crate::types::{CardKind, ConvertOptions};
53    use serde_json::json;
54
55    #[test]
56    fn happy_path_two_cards() {
57        let cards = vec![
58            CardEntry {
59                id: "welcome".into(),
60                kind: CardKind::AdaptiveCard(json!({
61                    "actions":[{"type":"Action.Submit","data":{"routeToCardId":"thanks","action_id":"go"}}]
62                })),
63            },
64            CardEntry {
65                id: "thanks".into(),
66                kind: CardKind::AdaptiveCard(json!({})),
67            },
68        ];
69        let res = convert(
70            &cards,
71            &ConvertOptions {
72                flow_name: "demo".into(),
73                strict: false,
74            },
75        )
76        .unwrap();
77        assert!(res.flow_yaml.contains("start: welcome"));
78        assert!(res.diagnostics.is_empty());
79    }
80
81    #[test]
82    fn empty_cards_errors() {
83        let err = convert(
84            &[],
85            &ConvertOptions {
86                flow_name: "x".into(),
87                strict: false,
88            },
89        )
90        .unwrap_err();
91        assert_eq!(err.code(), "E_NO_CARDS");
92    }
93
94    #[test]
95    fn unreachable_card_emits_diagnostic() {
96        let cards = vec![
97            CardEntry {
98                id: "a".into(),
99                kind: CardKind::AdaptiveCard(json!({})),
100            },
101            CardEntry {
102                id: "orphan".into(),
103                kind: CardKind::AdaptiveCard(json!({})),
104            },
105        ];
106        let res = convert(
107            &cards,
108            &ConvertOptions {
109                flow_name: "demo".into(),
110                strict: false,
111            },
112        )
113        .unwrap();
114        assert!(
115            res.diagnostics
116                .iter()
117                .any(|d| matches!(d.kind, crate::types::DiagnosticKind::UnreachableCard))
118        );
119    }
120}