tramex_tools/interface/parser/
mod.rs

1//! Parser for file interface
2
3pub mod hex_extractor;
4pub mod parser_basic;
5pub mod parser_gtpu;
6pub mod parser_nas;
7pub mod parser_ngap;
8pub mod parser_phy;
9pub mod parser_rrc;
10
11use crate::data::AdditionalInfos;
12use crate::data::Trace;
13
14use crate::errors::ErrorCode;
15use crate::errors::TramexError;
16use crate::interface::layer::Layer;
17use crate::interface::types::Direction;
18use crate::tramex_error;
19use chrono::NaiveTime;
20use chrono::Timelike;
21use std::str::FromStr;
22
23/// Common metadata extracted from either a file header line or WebSocket JSON fields.
24/// This serves as the unified input to all layer parsers.
25#[derive(Debug, Clone, Default)]
26pub struct ParsedHeader {
27    /// Timestamp in milliseconds
28    pub timestamp: i64,
29    /// Protocol layer
30    pub layer: Layer,
31    /// Direction (UL/DL/TO/FROM)
32    pub direction: Direction,
33    /// UE identifier
34    pub ue_id: Option<u64>,
35    /// Cell identifier
36    pub cell: Option<u64>,
37    /// RNTI
38    pub rnti: Option<u64>,
39    /// Frame number (PHY)
40    pub frame: Option<u16>,
41    /// Slot number (PHY)
42    pub slot: Option<u8>,
43    /// Channel name (PHY: PDCCH, PDSCH, etc.)
44    pub channel: Option<String>,
45    /// Connection info (NGAP/GTPU: IP:port)
46    pub connection_info: Option<String>,
47}
48
49impl ParsedHeader {
50    /// Build a ParsedHeader by parsing the first line of a file-format trace.
51    /// File format: "HH:MM:SS.mmm [LAYER] DIR id1 id2 id3 ... payload"
52    ///
53    /// # Errors
54    /// Returns a ParsingError if the line cannot be parsed.
55    pub fn from_file_line(first_line: &str) -> Result<Self, ParsingError> {
56        let parts: Vec<&str> = first_line.split_whitespace().collect();
57        if parts.len() < 3 {
58            return Err(ParsingError::new(format!("Not enough parts in line: {}", first_line), 0));
59        }
60
61        let timestamp = parse_timestamp(first_line)?;
62
63        let layer = Layer::from_str(parts[1].trim_start_matches('[').trim_end_matches(']'))
64            .map_err(|_| ParsingError::new(format!("Unknown layer: {}", parts[1]), 0))?;
65
66        let direction = Direction::from_str(parts[2]).unwrap_or(Direction::NA);
67
68        // For NGAP/GTPU, extract connection_info from the parts
69        let connection_info = match layer {
70            Layer::NGAP => {
71                if parts.len() > 5 && parts[5].contains(':') {
72                    Some(parts[5].to_string())
73                } else {
74                    None
75                }
76            }
77            Layer::GTPU => {
78                if parts.len() > 3 && parts[3].contains(':') {
79                    Some(parts[3].to_string())
80                } else {
81                    None
82                }
83            }
84            _ => None,
85        };
86
87        Ok(Self {
88            timestamp,
89            layer,
90            direction,
91            ue_id: None,
92            cell: None,
93            rnti: None,
94            frame: None,
95            slot: None,
96            channel: None,
97            connection_info,
98        })
99    }
100}
101
102/// Parsing error
103#[derive(Debug)]
104pub struct ParsingError {
105    /// Error message
106    pub message: String,
107
108    /// Line index
109    pub line_idx: u64,
110}
111
112impl ParsingError {
113    /// Create a new parsing error
114    pub fn new(message: String, line_idx: u64) -> Self {
115        Self { message, line_idx }
116    }
117}
118
119/// Convert a parsing error to a tramex error
120#[inline]
121pub fn parsing_error_to_tramex_error(error: ParsingError, idx: u64) -> TramexError {
122    let index = idx + error.line_idx;
123    tramex_error!(format!("{} (line {})", error.message, index), ErrorCode::FileParsing)
124}
125
126/// Trait for file parser (legacy — kept for backward compatibility)
127pub trait FileParser {
128    /// Function that parses the first line of a log
129    /// # Errors
130    /// Return an error if the parsing fails
131    fn parse_additional_infos(line: &[String]) -> Result<AdditionalInfos, ParsingError>;
132
133    /// Parse the lines of a file
134    /// # Errors
135    /// Return an error if the parsing fails
136    fn parse(lines: &[String]) -> Result<Trace, ParsingError>;
137}
138
139/// Unified layer parser trait.
140/// Takes a ParsedHeader (built from either file or WebSocket) plus payload data lines
141/// and produces AdditionalInfos for that layer.
142pub trait LayerParser {
143    /// Parse payload data lines given the pre-extracted header metadata.
144    /// # Errors
145    /// Return an error if the parsing fails
146    fn parse_layer(header: &ParsedHeader, data_lines: &[String]) -> Result<AdditionalInfos, ParsingError>;
147}
148
149/// Extract the payload portion of file-format lines by stripping the header from the first line.
150/// For a file line like "13:20:58.310 [RRC] DL 0001 01 4601  DCCH-NR: RRC release",
151/// this returns the part after the header prefix that is layer-specific payload.
152pub fn extract_file_payload(first_line: &str, layer: &Layer) -> String {
153    let parts: Vec<&str> = first_line.split_whitespace().collect();
154    // The payload start index depends on the layer format:
155    // RRC:  "TIME [RRC] DIR ID1 ID2 ID3 CANAL: MSG" → parts[5..]
156    // NAS:  "TIME [NAS] DIR ID1 PROTO: MSG" → parts[4..]
157    // NGAP: "TIME [NGAP] DIR ID1 ID2 IP:PORT MSG..." → parts[6..] (or 5 if no connection)
158    // GTPU: "TIME [GTPU] DIR IP:PORT MSG..." → parts[4..]
159    // PHY:  entire line is needed for parse_phy_lines
160    match layer {
161        Layer::RRC => {
162            if parts.len() > 5 {
163                parts[5..].join(" ")
164            } else {
165                first_line.to_string()
166            }
167        }
168        Layer::NAS => {
169            if parts.len() > 4 {
170                parts[4..].join(" ")
171            } else {
172                first_line.to_string()
173            }
174        }
175        Layer::NGAP => {
176            // Skip: TIME [NGAP] DIR ID1 ID2 IP:PORT → payload starts at 6
177            let start = if parts.len() > 5 && parts[5].contains(':') { 6 } else { 5 };
178            if parts.len() > start {
179                parts[start..].join(" ")
180            } else {
181                first_line.to_string()
182            }
183        }
184        Layer::GTPU => {
185            // Skip: TIME [GTPU] DIR IP:PORT → payload starts at 4
186            let start = if parts.len() > 3 && parts[3].contains(':') { 4 } else { 3 };
187            if parts.len() > start {
188                parts[start..].join(" ")
189            } else {
190                first_line.to_string()
191            }
192        }
193        _ => first_line.to_string(),
194    }
195}
196
197/// Build a Trace from a ParsedHeader, AdditionalInfos, and data lines.
198/// This is the single assembly point used by both file and WebSocket paths.
199pub fn build_trace(header: &ParsedHeader, additional_infos: AdditionalInfos, data_lines: &[String]) -> Trace {
200    use crate::interface::association::TraceRelation;
201    let binary = hex_extractor::extract_binary_from_lines(data_lines);
202    Trace {
203        timestamp: header.timestamp,
204        layer: header.layer.clone(),
205        additional_infos,
206        text: Some(data_lines.to_vec()),
207        binary,
208        relation: TraceRelation::default(),
209    }
210}
211
212/// Convert a time to milliseconds.
213#[inline]
214pub fn time_to_milliseconds(time: &NaiveTime) -> i64 {
215    let hours_in_ms = time.hour() as i64 * 3_600_000;
216    let minutes_in_ms = time.minute() as i64 * 60_000;
217    let seconds_in_ms = time.second() as i64 * 1000;
218    let milliseconds = time.nanosecond() as i64 / 1_000_000; // convert nanoseconds to milliseconds
219
220    hours_in_ms + minutes_in_ms + seconds_in_ms + milliseconds
221}
222
223/// Parse timestamp from the first line of a trace
224/// Expected format: "HH:MM:SS.mmm [LAYER] ..."
225/// # Errors
226/// Returns ParsingError if timestamp cannot be parsed
227pub fn parse_timestamp(first_line: &str) -> Result<i64, ParsingError> {
228    let parts: Vec<&str> = first_line.split_whitespace().collect();
229    if parts.is_empty() {
230        return Err(ParsingError::new("Empty line, cannot parse timestamp".to_string(), 0));
231    }
232
233    let date = chrono::NaiveTime::parse_from_str(parts[0], "%H:%M:%S%.3f")
234        .map_err(|_| ParsingError::new(format!("Error parsing timestamp '{}' in line: {}", parts[0], first_line), 0))?;
235
236    Ok(time_to_milliseconds(&date))
237}
238
239/// Build a eof_error
240#[inline]
241pub fn eof_error(line_idx: u64) -> TramexError {
242    tramex_error!(
243        format!("End of file (line {})", line_idx),
244        crate::errors::ErrorCode::EndOfFile
245    )
246}