Skip to main content

fints/
debug.rs

1//! Protocol debugging and wire format inspection utilities.
2//!
3//! Used by both the CLI client and mock server for detailed logging.
4
5use crate::error::FinTSError;
6use crate::parser::{parse_message, DataElement};
7
8// ── Public types ─────────────────────────────────────────────────────────────
9
10/// Controls how much detail is emitted in formatted output.
11pub enum VerbosityLevel {
12    /// Only show segment type names.
13    Minimal,
14    /// Show segment types + data element values (no binary hex).
15    Segments,
16    /// Show everything including binary hex dumps.
17    Full,
18}
19
20/// A decoded FinTS segment ready for display.
21pub struct DecodedSegment {
22    pub segment_type: String,
23    pub segment_number: u16,
24    pub segment_version: u16,
25    pub segment_reference: Option<u16>,
26    /// DEGs → DEs as display strings.
27    pub degs: Vec<Vec<String>>,
28}
29
30/// A decoded FinTS message ready for human display.
31pub struct DecodedMessage {
32    pub segments: Vec<DecodedSegment>,
33    /// Global-level response codes: (code, text).
34    pub global_codes: Vec<(String, String)>,
35    /// Segment-level response codes: (code, text).
36    pub segment_codes: Vec<(String, String)>,
37    pub raw_bytes: usize,
38}
39
40// ── Core public functions ─────────────────────────────────────────────────────
41
42/// Parse raw bytes into a [`DecodedMessage`].
43pub fn decode_message(data: &[u8]) -> Result<DecodedMessage, FinTSError> {
44    let raw_segments = parse_message(data)?;
45    let raw_bytes = data.len();
46
47    let mut segments: Vec<DecodedSegment> = Vec::new();
48    let mut global_codes: Vec<(String, String)> = Vec::new();
49    let mut segment_codes: Vec<(String, String)> = Vec::new();
50
51    for raw in &raw_segments {
52        let seg_type = raw.segment_type().to_string();
53        let seg_num = raw.segment_number();
54        let seg_ver = raw.segment_version();
55        let seg_ref = raw.segment_reference();
56
57        // Build display-string DEGs (skip header DEG 0).
58        let degs: Vec<Vec<String>> = raw
59            .degs
60            .iter()
61            .skip(1)
62            .map(|deg| {
63                deg.0
64                    .iter()
65                    .map(|de| de_to_display_string(de, &seg_type, VerbosityLevel::Full))
66                    .collect()
67            })
68            .collect();
69
70        // Collect response codes from HIRMG and HIRMS.
71        match seg_type.as_str() {
72            "HIRMG" => {
73                for deg in raw.degs.iter().skip(1) {
74                    let code = deg.get_str(0);
75                    let text = deg.get_str(2);
76                    if !code.is_empty() {
77                        global_codes.push((code, text));
78                    }
79                }
80            }
81            "HIRMS" => {
82                for deg in raw.degs.iter().skip(1) {
83                    let code = deg.get_str(0);
84                    let text = deg.get_str(2);
85                    if !code.is_empty() {
86                        segment_codes.push((code, text));
87                    }
88                }
89            }
90            _ => {}
91        }
92
93        segments.push(DecodedSegment {
94            segment_type: seg_type,
95            segment_number: seg_num,
96            segment_version: seg_ver,
97            segment_reference: seg_ref,
98            degs,
99        });
100    }
101
102    Ok(DecodedMessage {
103        segments,
104        global_codes,
105        segment_codes,
106        raw_bytes,
107    })
108}
109
110/// Produce a human-readable multi-line string for a [`DecodedMessage`].
111pub fn format_decoded(msg: &DecodedMessage, verbosity: VerbosityLevel) -> String {
112    let mut out = String::new();
113
114    out.push_str(&format!("FinTS message ({} bytes)\n", msg.raw_bytes));
115
116    if !msg.global_codes.is_empty() {
117        out.push_str("  Global codes:\n");
118        for (code, text) in &msg.global_codes {
119            out.push_str(&format!("    {} — {}\n", code, text));
120        }
121    }
122    if !msg.segment_codes.is_empty() {
123        out.push_str("  Segment codes:\n");
124        for (code, text) in &msg.segment_codes {
125            out.push_str(&format!("    {} — {}\n", code, text));
126        }
127    }
128
129    out.push_str("  Segments:\n");
130    for seg in &msg.segments {
131        match verbosity {
132            VerbosityLevel::Minimal => {
133                out.push_str(&format!("    {}\n", seg.segment_type));
134            }
135            VerbosityLevel::Segments | VerbosityLevel::Full => {
136                let ref_part = seg
137                    .segment_reference
138                    .map(|r| format!(":{}", r))
139                    .unwrap_or_default();
140                out.push_str(&format!(
141                    "    {}:{}:{}{}",
142                    seg.segment_type, seg.segment_number, seg.segment_version, ref_part
143                ));
144
145                for deg in &seg.degs {
146                    let deg_str = deg.join(":");
147                    out.push_str(&format!(" + {}", deg_str));
148                }
149                out.push('\n');
150            }
151        }
152    }
153
154    out
155}
156
157/// Classic hex dump: 16 bytes per line, offset | hex | ascii.
158pub fn hex_dump(data: &[u8]) -> String {
159    let mut out = String::new();
160    for (chunk_idx, chunk) in data.chunks(16).enumerate() {
161        let offset = chunk_idx * 16;
162        // Offset column
163        out.push_str(&format!("{:08x}  ", offset));
164        // Hex columns
165        for (i, byte) in chunk.iter().enumerate() {
166            out.push_str(&format!("{:02x} ", byte));
167            if i == 7 {
168                out.push(' ');
169            }
170        }
171        // Padding if last line is short
172        if chunk.len() < 16 {
173            let missing = 16 - chunk.len();
174            for i in 0..missing {
175                out.push_str("   ");
176                if chunk.len() + i == 7 {
177                    out.push(' ');
178                }
179            }
180        }
181        out.push_str(" |");
182        // ASCII column
183        for byte in chunk {
184            if byte.is_ascii_graphic() || *byte == b' ' {
185                out.push(*byte as char);
186            } else {
187                out.push('.');
188            }
189        }
190        out.push_str("|\n");
191    }
192    out
193}
194
195/// Combine a label + optional hex dump + decoded segments.
196pub fn format_wire_log(label: &str, data: &[u8], verbosity: VerbosityLevel) -> String {
197    let mut out = String::new();
198    out.push_str(&format!("=== {} ({} bytes) ===\n", label, data.len()));
199
200    if matches!(verbosity, VerbosityLevel::Full) {
201        out.push_str(&hex_dump(data));
202        out.push('\n');
203    }
204
205    match decode_message(data) {
206        Ok(msg) => out.push_str(&format_decoded(&msg, verbosity)),
207        Err(e) => out.push_str(&format!("  [parse error: {}]\n", e)),
208    }
209
210    out
211}
212
213// ── Private helpers ───────────────────────────────────────────────────────────
214
215/// Convert a single `DataElement` to a display string, respecting verbosity
216/// and applying HNSHA redaction.
217fn de_to_display_string(de: &DataElement, seg_type: &str, verbosity: VerbosityLevel) -> String {
218    match de {
219        DataElement::Empty => String::new(),
220        DataElement::Text(s) => s.clone(),
221        DataElement::Binary(b) => {
222            if seg_type == "HNSHA" {
223                return "[REDACTED IN HNSHA]".to_string();
224            }
225            match verbosity {
226                VerbosityLevel::Minimal | VerbosityLevel::Segments => {
227                    format!("[BINARY: {} bytes]", b.len())
228                }
229                VerbosityLevel::Full => {
230                    let hex: String = b.iter().map(|byte| format!("{:02x}", byte)).collect();
231                    format!("[BINARY: {}]", hex)
232                }
233            }
234        }
235    }
236}
237
238// ── Tests ─────────────────────────────────────────────────────────────────────
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn test_decode_simple_message() {
246        let data = b"HNHBS:5:1+2'";
247        let msg = decode_message(data).expect("decode should succeed");
248        assert_eq!(msg.segments.len(), 1);
249        assert_eq!(msg.segments[0].segment_type, "HNHBS");
250        assert_eq!(msg.segments[0].segment_number, 5);
251        assert_eq!(msg.raw_bytes, 12);
252    }
253
254    #[test]
255    fn test_decode_collects_global_codes() {
256        let data = b"HIRMG:3:2+0010::Nachricht entgegengenommen.'HNHBS:4:1+1'";
257        let msg = decode_message(data).expect("decode should succeed");
258        assert_eq!(msg.global_codes.len(), 1);
259        assert_eq!(msg.global_codes[0].0, "0010");
260        assert_eq!(msg.global_codes[0].1, "Nachricht entgegengenommen.");
261    }
262
263    #[test]
264    fn test_format_minimal_verbosity() {
265        let data = b"HNHBS:5:1+2'";
266        let msg = decode_message(data).expect("decode should succeed");
267        let formatted = format_decoded(&msg, VerbosityLevel::Minimal);
268        assert!(formatted.contains("HNHBS"));
269        // Minimal should not show segment numbers inline with data
270        assert!(!formatted.contains("HNHBS:5:1"));
271    }
272
273    #[test]
274    fn test_format_segments_verbosity() {
275        let data = b"HNHBS:5:1+2'";
276        let msg = decode_message(data).expect("decode should succeed");
277        let formatted = format_decoded(&msg, VerbosityLevel::Segments);
278        assert!(formatted.contains("HNHBS:5:1"));
279        assert!(formatted.contains("+ 2"));
280    }
281
282    #[test]
283    fn test_hex_dump_format() {
284        let data = b"HNHBS:5:1";
285        let dump = hex_dump(data);
286        // Should start with offset 00000000
287        assert!(dump.starts_with("00000000"));
288        // Should have a pipe-delimited ASCII column
289        assert!(dump.contains('|'));
290        assert!(dump.contains("HNHBS:5:1"));
291    }
292
293    #[test]
294    fn test_binary_redacted_in_hnsha() {
295        let binary = DataElement::Binary(b"secret_pin".to_vec());
296        let display = de_to_display_string(&binary, "HNSHA", VerbosityLevel::Full);
297        assert_eq!(display, "[REDACTED IN HNSHA]");
298    }
299
300    #[test]
301    fn test_binary_shown_at_full_verbosity() {
302        let binary = DataElement::Binary(vec![0xde, 0xad]);
303        let display = de_to_display_string(&binary, "HNVSD", VerbosityLevel::Full);
304        assert!(display.contains("dead"));
305    }
306
307    #[test]
308    fn test_binary_shown_as_size_at_segments_verbosity() {
309        let binary = DataElement::Binary(vec![0u8; 42]);
310        let display = de_to_display_string(&binary, "HNVSD", VerbosityLevel::Segments);
311        assert_eq!(display, "[BINARY: 42 bytes]");
312    }
313
314    #[test]
315    fn test_format_wire_log_contains_label() {
316        let data = b"HNHBS:5:1+2'";
317        let log = format_wire_log("OUTBOUND", data, VerbosityLevel::Minimal);
318        assert!(log.contains("OUTBOUND"));
319        assert!(log.contains("12 bytes"));
320    }
321
322    #[test]
323    fn test_decode_message_with_segment_reference() {
324        let data = b"HIRMS:4:2:3+0010::Nachricht entgegengenommen.'";
325        let msg = decode_message(data).expect("decode should succeed");
326        assert_eq!(msg.segments[0].segment_reference, Some(3));
327        assert_eq!(msg.segment_codes.len(), 1);
328    }
329}