Skip to main content

bgpkit_parser/parser/
text_dump.rs

1//! Cisco `sh ip bgp` text dump parser.
2//!
3//! Parses the fixed-width column format produced by Cisco IOS routers, as
4//! published by PCH (daily routing table snapshots) and route-views
5//! (`oix-full-snapshot-*.bz2`). Field boundaries are extracted from the table
6//! header so that numeric attributes do not become indistinguishable from
7//! numeric AS-path segments.
8//!
9//! # Format
10//!
11//! ```text
12//! BGP table version is N, local router ID is X.X.X.X, vrf id 0
13//! Default local pref 100, local AS NNNN
14//! ...
15//!     Network          Next Hop            Metric LocPrf Weight Path
16//!  *> 1.0.0.0/24       103.77.108.118           0             0 13335 i
17//!  *=                  103.77.108.11            0             0 13335 i
18//! ```
19//!
20//! Route-views `sh ip bgp` snapshots (e.g. `oix-full-snapshot-*.bz2`) use the
21//! same fixed-width layout but omit the `BGP table version` / `local AS`
22//! preamble. When the preamble is absent, `peer_ip` and `peer_asn` default to
23//! the unspecified sentinel values `0.0.0.0` and AS0.
24//!
25//! # Example
26//!
27//! ```no_run
28//! use bgpkit_parser::parser::text_dump::{infer_timestamp_from_path, parse_text_dump_with_timestamp};
29//!
30//! let url = "https://downloads.pch.net/files/Routing_Data/IPv4_daily_snapshots/2026/07/route-collector.bom2.pch.net/route-collector.bom2.pch.net-ipv4_bgp_routes.2026.07.01.gz";
31//! let reader = oneio::get_reader(url).unwrap();
32//! let mut reader = std::io::BufReader::new(reader);
33//! let timestamp = infer_timestamp_from_path(url).unwrap_or(0.0);
34//! let elems = parse_text_dump_with_timestamp(&mut reader, timestamp).unwrap();
35//! println!("parsed {} elements", elems.len());
36//! ```
37
38use crate::models::*;
39use ipnet::IpNet;
40use std::io::{BufRead, Read};
41use std::net::IpAddr;
42
43/// Byte offsets for the Cisco `Next Hop`, `Metric`, `LocPrf`, `Weight`, and
44/// `Path` columns, respectively.
45type ColumnPositions = (usize, usize, usize, usize, usize);
46
47/// Header metadata extracted from the preamble of a Cisco `sh ip bgp` dump.
48#[derive(Debug, Clone)]
49pub struct TextDumpHeader {
50    /// BGP table version, when the preamble carries one (absent in route-views dumps).
51    pub table_version: Option<u64>,
52    /// Local router ID, used as the `peer_ip` of parsed elements (absent in route-views dumps).
53    pub router_id: Option<IpAddr>,
54    /// Local AS number, used as the `peer_asn` of parsed elements (absent in route-views dumps).
55    pub local_as: Option<u32>,
56    column_positions: Option<ColumnPositions>,
57}
58
59// ── Detection ──────────────────────────────────────────────────────
60
61/// Detect whether a reader contains a Cisco `sh ip bgp` text dump.
62///
63/// Reads up to 256 bytes and returns them alongside the detection result so
64/// callers can chain the buffered bytes into the actual parser.
65pub fn detect_text_dump<R: Read>(mut reader: R) -> std::io::Result<(bool, Vec<u8>)> {
66    let mut buf = vec![0u8; 256];
67    let n = reader.read(&mut buf)?;
68    buf.truncate(n);
69
70    let is_text = n > 0
71        && buf[0].is_ascii_graphic()
72        && std::str::from_utf8(&buf[..n.min(128)]).is_ok_and(|s| {
73            s.contains("BGP table") || s.contains("Next Hop") || s.contains("Status codes:")
74        });
75
76    Ok((is_text, buf))
77}
78
79// ── Header parsing ─────────────────────────────────────────────────
80
81/// Extract column offsets from the Cisco table header using whitespace split.
82///
83/// Splits the header line into whitespace-delimited tokens, merges known
84/// multi-word field names (e.g. "Next" + "Hop" → "Next Hop"), and records
85/// the byte position of each column in the original header line.
86fn parse_column_header(line: &str) -> Option<ColumnPositions> {
87    // Tokenize by whitespace: record (byte_position, token) for each word.
88    let bytes = line.as_bytes();
89    let mut tokens: Vec<(usize, &str)> = Vec::new();
90    let mut i = 0;
91
92    while i < bytes.len() {
93        // Skip leading whitespace.
94        while i < bytes.len() && bytes[i].is_ascii_whitespace() {
95            i += 1;
96        }
97        if i >= bytes.len() {
98            break;
99        }
100        let start = i;
101        // Consume non-whitespace characters.
102        while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
103            i += 1;
104        }
105        let token = std::str::from_utf8(&bytes[start..i]).ok()?;
106        tokens.push((start, token));
107    }
108
109    if tokens.is_empty() {
110        return None;
111    }
112
113    // Merge known multi-word field names.
114    // "Next" immediately followed by "Hop" → "Next Hop" starting at "Next".
115    let mut columns: Vec<(usize, String)> = Vec::new();
116    let mut skip = false;
117    for idx in 0..tokens.len() {
118        if skip {
119            skip = false;
120            continue;
121        }
122        let (pos, token) = tokens[idx];
123        if token == "Next" && idx + 1 < tokens.len() && tokens[idx + 1].1 == "Hop" {
124            columns.push((pos, "Next Hop".to_string()));
125            skip = true;
126        } else {
127            columns.push((pos, token.to_string()));
128        }
129    }
130
131    // Extract positions for the five columns we depend on.
132    let target_names = ["Next Hop", "Metric", "LocPrf", "Weight", "Path"];
133    let mut positions: [Option<usize>; 5] = [None; 5];
134
135    for (pos, name) in &columns {
136        for (i, target) in target_names.iter().enumerate() {
137            if name == *target {
138                positions[i] = Some(*pos);
139            }
140        }
141    }
142
143    let next_hop = positions[0]?;
144    let metric = positions[1]?;
145    let local_pref = positions[2]?;
146    let weight = positions[3]?;
147    let path = positions[4]?;
148
149    if next_hop < metric && metric < local_pref && local_pref < weight && weight < path {
150        Some((next_hop, metric, local_pref, weight, path))
151    } else {
152        None
153    }
154}
155
156/// Parse the preamble to extract router metadata and fixed-width column offsets.
157///
158/// Consumes lines up to and including the column header line
159/// (`Network / Next Hop / Metric / LocPrf / Weight / Path`); the reader is left
160/// positioned at the first route line.
161pub fn parse_header<R: BufRead>(reader: &mut R) -> std::io::Result<TextDumpHeader> {
162    let mut header = TextDumpHeader {
163        table_version: None,
164        router_id: None,
165        local_as: None,
166        column_positions: None,
167    };
168    let mut buf = String::new();
169
170    for _ in 0..64 {
171        buf.clear();
172        if reader.read_line(&mut buf)? == 0 {
173            break;
174        }
175        let line = buf.trim_end();
176
177        if line.starts_with("BGP table version") {
178            if let Some(rest) = line.strip_prefix("BGP table version is ") {
179                if let Some((ver_str, rest)) = rest.split_once(',') {
180                    header.table_version = ver_str.trim().parse().ok();
181                    if let Some(rid_part) = rest.split("local router ID is ").nth(1) {
182                        if let Some((rid_str, _)) = rid_part.split_once(',') {
183                            header.router_id = rid_str.trim().parse().ok();
184                        }
185                    }
186                }
187            }
188        }
189
190        if line.starts_with("Default local pref") {
191            if let Some(rest) = line.split("local AS ").nth(1) {
192                header.local_as = rest.trim().parse().ok();
193            }
194        }
195
196        if let Some(column_positions) = parse_column_header(line) {
197            header.column_positions = Some(column_positions);
198            break;
199        }
200    }
201    Ok(header)
202}
203
204// ── Fixed-width route parsing ──────────────────────────────────────
205
206/// A parsed route entry from a single line.
207#[derive(Debug, Clone)]
208struct RouteEntry {
209    prefix: String,
210    next_hop: String,
211    metric: Option<u32>,
212    local_pref: Option<u32>,
213    as_path: Vec<String>,
214    origin: Option<Origin>,
215}
216
217fn parse_u32_column(line: &str, start: usize, end: usize) -> Option<u32> {
218    line.get(start..end)?.trim().parse().ok()
219}
220
221fn shift_columns_left(columns: ColumnPositions) -> Option<ColumnPositions> {
222    Some((
223        columns.0.checked_sub(1)?,
224        columns.1.checked_sub(1)?,
225        columns.2.checked_sub(1)?,
226        columns.3.checked_sub(1)?,
227        columns.4.checked_sub(1)?,
228    ))
229}
230
231/// Detect a "wrapped" prefix-only line (no route data, just a prefix after
232/// the status flags). The next-hop column position is used to distinguish
233/// prefix-only lines from full route lines: if the line segment at the
234/// next-hop position is empty, the line carries only a prefix.
235fn parse_wrapped_prefix_line(line: &str, next_hop_start: usize) -> Option<String> {
236    // If the line extends into the next-hop column, check whether the
237    // characters there form route data (digits/IP) or are just whitespace.
238    if line.len() > next_hop_start {
239        let rest = line[next_hop_start..].trim();
240        if !rest.is_empty() {
241            return None; // has route data → regular line, not a wrapped prefix
242        }
243    }
244
245    let prefix = if line.len() > next_hop_start {
246        line.get(3..next_hop_start)?
247    } else {
248        line.get(3..)?
249    }
250    .trim();
251    prefix.parse::<IpNet>().ok().map(|_| prefix.to_string())
252}
253
254/// Parse a single Cisco route line using positions extracted from its column header.
255fn parse_route_line(line: &str, columns: ColumnPositions) -> Option<RouteEntry> {
256    let line = line.trim_end();
257    if line.trim().is_empty() || line.trim_start().starts_with("Displayed") {
258        return None;
259    }
260
261    let (next_hop_start, metric_start, local_pref_start, weight_start, path_start) = columns;
262    let next_hop = line.get(next_hop_start..metric_start)?.trim();
263    if next_hop.parse::<IpAddr>().is_err() {
264        return None;
265    }
266
267    let prefix = line
268        .get(3..next_hop_start)
269        .unwrap_or_default()
270        .trim()
271        .to_string();
272    let metric = parse_u32_column(line, metric_start, local_pref_start);
273    let local_pref = parse_u32_column(line, local_pref_start, weight_start);
274    // Cisco weight is a router-local attribute with no `BgpElem` representation.
275    let _weight = parse_u32_column(line, weight_start, path_start);
276
277    let path_tokens: Vec<&str> = line
278        .get(path_start..)
279        .unwrap_or_default()
280        .split_whitespace()
281        .collect();
282    let origin = match path_tokens.last().copied() {
283        Some("i") => Some(Origin::IGP),
284        Some("e") => Some(Origin::EGP),
285        Some("?") => Some(Origin::INCOMPLETE),
286        _ => None,
287    };
288    let path_end = path_tokens.len() - usize::from(origin.is_some());
289    let as_path = path_tokens[..path_end]
290        .iter()
291        .map(|token| (*token).to_string())
292        .collect();
293
294    Some(RouteEntry {
295        prefix,
296        next_hop: next_hop.to_string(),
297        metric,
298        local_pref,
299        as_path,
300        origin,
301    })
302}
303
304// ── BgpElem construction ───────────────────────────────────────────
305
306/// Convert path tokens into an AsPath.
307///
308/// AS-set delimiters (`{` / `}`) are silently dropped, flattening AS-sets
309/// into plain AS-sequences. This is intentional: the parser aims to recover
310/// the AS-level propagation path, and set membership is not preserved.
311///
312/// Only bare integer tokens are recognized (e.g. `13335`, `4755`). Cisco
313/// `sh ip bgp` output never uses the `AS{n}` notation, so `Asn`'s `FromStr`
314/// (which handles that syntax) is not needed here.
315fn as_path_from_tokens(tokens: &[String]) -> AsPath {
316    let mut asns: Vec<Asn> = Vec::new();
317    for token in tokens {
318        if token == "{" || token == "}" {
319            continue;
320        }
321        if let Ok(asn) = token.parse::<u32>() {
322            asns.push(Asn::from(asn));
323        }
324    }
325    if asns.is_empty() {
326        return AsPath {
327            segments: vec![AsPathSegment::AsSequence(Default::default())].into(),
328        };
329    }
330    AsPath {
331        segments: vec![AsPathSegment::AsSequence(asns.into())].into(),
332    }
333}
334
335fn entry_to_elem(
336    entry: &RouteEntry,
337    prefix_str: &str,
338    peer_ip: IpAddr,
339    peer_asn: u32,
340    timestamp: f64,
341) -> Option<BgpElem> {
342    let prefix: IpNet = match prefix_str.parse() {
343        Ok(p) => p,
344        Err(_) => return None,
345    };
346
347    let network_prefix = NetworkPrefix::new(prefix, None);
348    let next_hop: Option<IpAddr> = entry.next_hop.parse().ok();
349    let as_path = Some(as_path_from_tokens(&entry.as_path));
350    let origin = entry.origin;
351    let local_pref = entry.local_pref;
352    let med = entry.metric;
353
354    let origin_asns: Option<Vec<Asn>> = as_path.as_ref().and_then(|ap| {
355        ap.segments
356            .last()
357            .and_then(|seg| match seg {
358                AsPathSegment::AsSequence(asns) => asns.last().copied(),
359                _ => None,
360            })
361            .map(|asn| vec![asn])
362    });
363
364    Some(BgpElem {
365        timestamp,
366        elem_type: ElemType::ANNOUNCE,
367        peer_ip,
368        peer_asn: Asn::from(peer_asn),
369        prefix: network_prefix,
370        next_hop,
371        as_path,
372        origin_asns,
373        origin,
374        local_pref,
375        med,
376        communities: None,
377        atomic: false,
378        aggr_asn: None,
379        aggr_ip: None,
380        only_to_customer: None,
381        unknown: None,
382        deprecated: None,
383        peer_bgp_id: None,
384    })
385}
386
387// ── Timestamp inference ────────────────────────────────────────────
388
389/// Try to extract a Unix timestamp from a file path or URL.
390///
391/// Recognises route-views-style timestamps like
392/// `oix-full-snapshot-2026-07-01-0000.bz2` (`YYYY-MM-DD-HHMM`, using the
393/// embedded time of day) and PCH-style date components like
394/// `...2026.07.01.gz` (`YYYY.MM.DD`, noon UTC). Returns `None` when no
395/// recognisable date is found.
396pub fn infer_timestamp_from_path(path: &str) -> Option<f64> {
397    let bytes = path.as_bytes();
398
399    // Scan for YYYY-MM-DD-HHMM pattern (route-views snapshot filenames).
400    for (idx, window) in bytes.windows(15).enumerate() {
401        if window[0].is_ascii_digit()
402            && window[1].is_ascii_digit()
403            && window[2].is_ascii_digit()
404            && window[3].is_ascii_digit()
405            && window[4] == b'-'
406            && window[5].is_ascii_digit()
407            && window[6].is_ascii_digit()
408            && window[7] == b'-'
409            && window[8].is_ascii_digit()
410            && window[9].is_ascii_digit()
411            && window[10] == b'-'
412            && window[11].is_ascii_digit()
413            && window[12].is_ascii_digit()
414            && window[13].is_ascii_digit()
415            && window[14].is_ascii_digit()
416        {
417            let y: Option<i32> = std::str::from_utf8(&window[0..4])
418                .ok()
419                .and_then(|s| s.parse().ok());
420            let m: Option<u32> = std::str::from_utf8(&window[5..7])
421                .ok()
422                .and_then(|s| s.parse().ok());
423            let d: Option<u32> = std::str::from_utf8(&window[8..10])
424                .ok()
425                .and_then(|s| s.parse().ok());
426            let hh: Option<u32> = std::str::from_utf8(&window[11..13])
427                .ok()
428                .and_then(|s| s.parse().ok());
429            let mm: Option<u32> = std::str::from_utf8(&window[13..15])
430                .ok()
431                .and_then(|s| s.parse().ok());
432            // Require a separator before the date to avoid matching digit
433            // runs inside longer numbers.
434            if idx > 0 && bytes[idx - 1].is_ascii_digit() {
435                continue;
436            }
437            if let (Some(y), Some(m), Some(d), Some(hh), Some(mm)) = (y, m, d, hh, mm) {
438                if let Some(dt) = chrono::NaiveDate::from_ymd_opt(y, m, d)
439                    .and_then(|date| date.and_hms_opt(hh, mm, 0))
440                {
441                    return Some(dt.and_utc().timestamp() as f64);
442                }
443            }
444        }
445    }
446
447    // Scan for YYYY.MM.DD pattern (PCH file URLs).
448    for window in bytes.windows(10) {
449        if window.len() == 10
450            && window[0].is_ascii_digit()
451            && window[1].is_ascii_digit()
452            && window[2].is_ascii_digit()
453            && window[3].is_ascii_digit()
454            && window[4] == b'.'
455            && window[5].is_ascii_digit()
456            && window[6].is_ascii_digit()
457            && window[7] == b'.'
458            && window[8].is_ascii_digit()
459            && window[9].is_ascii_digit()
460        {
461            let y: i32 = std::str::from_utf8(&window[0..4]).ok()?.parse().ok()?;
462            let m: u32 = std::str::from_utf8(&window[5..7]).ok()?.parse().ok()?;
463            let d: u32 = std::str::from_utf8(&window[8..10]).ok()?.parse().ok()?;
464            // Use noon UTC to avoid DST boundary issues.
465            let dt = chrono::NaiveDate::from_ymd_opt(y, m, d)?.and_hms_opt(12, 0, 0)?;
466            return Some(dt.and_utc().timestamp() as f64);
467        }
468    }
469    None
470}
471
472// ── Streaming iterator ─────────────────────────────────────────────
473
474/// A streaming iterator that yields [`BgpElem`]s from a Cisco `sh ip bgp`
475/// text dump, one route line at a time.
476///
477/// Created by [`TextDumpElemIterator::new`], which consumes the preamble
478/// (header + column definitions) and leaves the reader positioned at the
479/// first route line. Each call to [`Iterator::next`] reads at most one line,
480/// so peak memory is O(1) regardless of dump size.
481///
482/// Continuation lines (multipath entries with empty prefix) reuse the
483/// most-recently-seen prefix, and wrapped-prefix lines update that prefix
484/// without yielding an element — both are handled in-stream.
485pub struct TextDumpElemIterator<R> {
486    reader: R,
487    column_positions: ColumnPositions,
488    wrapped_column_positions: Option<ColumnPositions>,
489    peer_ip: IpAddr,
490    peer_asn: u32,
491    timestamp: f64,
492    current_prefix: String,
493    buf: String,
494}
495
496impl<R: BufRead> TextDumpElemIterator<R> {
497    /// Create a streaming text-dump element iterator.
498    ///
499    /// Reads and discards the preamble (up to and including the column header
500    /// line). All yielded elements carry the given `timestamp`.
501    pub fn new(mut reader: R, timestamp: f64) -> std::io::Result<Self> {
502        let header = parse_header(&mut reader)?;
503        let column_positions = match header.column_positions {
504            Some(positions) => positions,
505            None => {
506                return Err(std::io::Error::new(
507                    std::io::ErrorKind::InvalidData,
508                    "missing Cisco BGP table column header",
509                ));
510            }
511        };
512        let peer_ip = header
513            .router_id
514            .unwrap_or_else(|| IpAddr::from([0, 0, 0, 0]));
515        let peer_asn = header.local_as.unwrap_or(0);
516
517        Ok(TextDumpElemIterator {
518            reader,
519            column_positions,
520            wrapped_column_positions: shift_columns_left(column_positions),
521            peer_ip,
522            peer_asn,
523            timestamp,
524            current_prefix: String::new(),
525            buf: String::new(),
526        })
527    }
528}
529
530impl<R: BufRead> Iterator for TextDumpElemIterator<R> {
531    type Item = BgpElem;
532
533    fn next(&mut self) -> Option<BgpElem> {
534        loop {
535            self.buf.clear();
536            match self.reader.read_line(&mut self.buf) {
537                Ok(0) => return None, // EOF
538                Ok(_) => {}
539                Err(e) => {
540                    // Iterator::next cannot propagate io::Error, so log the
541                    // failure rather than silently truncating. Note that the
542                    // collect-all wrapper (parse_text_dump_with_timestamp)
543                    // also loses the error since it uses this iterator
544                    // internally; callers needing error propagation should
545                    // construct the iterator and inspect logs.
546                    log::warn!("text-dump read error, stopping iteration: {e}");
547                    return None;
548                }
549            }
550
551            let line = self.buf.trim_end();
552            if line.is_empty() {
553                continue;
554            }
555
556            // Try parsing as a fixed-width route line (standard or shifted columns).
557            let entry = parse_route_line(line, self.column_positions).or_else(|| {
558                self.wrapped_column_positions
559                    .and_then(|positions| parse_route_line(line, positions))
560            });
561            if let Some(entry) = entry {
562                if !entry.prefix.is_empty() {
563                    self.current_prefix = entry.prefix.clone();
564                }
565                if self.current_prefix.is_empty() {
566                    continue;
567                }
568                if let Some(elem) = entry_to_elem(
569                    &entry,
570                    &self.current_prefix,
571                    self.peer_ip,
572                    self.peer_asn,
573                    self.timestamp,
574                ) {
575                    return Some(elem);
576                }
577                continue;
578            }
579
580            // Wrapped-prefix-only line: update prefix, no element yielded.
581            if let Some(prefix) = parse_wrapped_prefix_line(line, self.column_positions.0) {
582                self.current_prefix = prefix;
583            }
584        }
585    }
586}
587
588// ── Convenience: collect-all wrappers ──────────────────────────────
589
590/// Parse a complete Cisco `sh ip bgp` text dump into [`BgpElem`]s with
591/// timestamp `0.0`.
592///
593/// See [`parse_text_dump_with_timestamp`] for details.
594pub fn parse_text_dump<R: BufRead>(reader: R) -> std::io::Result<Vec<BgpElem>> {
595    parse_text_dump_with_timestamp(reader, 0.0)
596}
597
598/// Parse a complete Cisco `sh ip bgp` text dump into [`BgpElem`]s.
599///
600/// All parsed elements share the given `timestamp`; use
601/// [`infer_timestamp_from_path`] to derive one from a PCH or route-views
602/// file name when available.
603///
604/// Internally this uses [`TextDumpElemIterator`] and collects the results.
605/// For streaming (constant-memory) usage, construct the iterator directly.
606///
607/// Route-views style snapshots omit the `BGP table version` / `local AS`
608/// preamble. The parser falls back to the unspecified sentinels rather than
609/// rejecting the dump: `0.0.0.0` and AS0 carry no peer identity.
610///
611/// Lines that do not parse as route entries (banner text, the trailing
612/// `Displayed ...` summary, malformed rows) are skipped silently.
613pub fn parse_text_dump_with_timestamp<R: BufRead>(
614    reader: R,
615    timestamp: f64,
616) -> std::io::Result<Vec<BgpElem>> {
617    let iter = TextDumpElemIterator::new(reader, timestamp)?;
618    Ok(iter.collect())
619}
620
621// ── Tests ──────────────────────────────────────────────────────────
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626
627    const TEST_COLUMNS: ColumnPositions = (21, 41, 48, 55, 62);
628    const TABLE_HEADER: &str = "    Network          Next Hop            Metric LocPrf Weight Path";
629
630    fn fixed_width_route(
631        prefix: &str,
632        next_hop: &str,
633        metric: &str,
634        local_pref: &str,
635        weight: &str,
636        path: &str,
637    ) -> String {
638        format!(
639            " *> {:<17}{:<20}{:>7}{:>7}{:>7}{}",
640            prefix, next_hop, metric, local_pref, weight, path
641        )
642    }
643
644    fn parsed_route(line: &str) -> RouteEntry {
645        match parse_route_line(line, TEST_COLUMNS) {
646            Some(entry) => entry,
647            None => panic!("expected a valid fixed-width route line"),
648        }
649    }
650
651    #[test]
652    fn test_detect_text_dump_positive() {
653        let data = b"BGP table version is 123, local router ID is 1.2.3.4, vrf id 0\n";
654        let (is_text, buf) = match detect_text_dump(&data[..]) {
655            Ok(result) => result,
656            Err(error) => panic!("text dump detection failed: {error}"),
657        };
658        assert!(is_text);
659        assert!(!buf.is_empty());
660    }
661
662    #[test]
663    fn test_detect_text_dump_negative() {
664        let data = [0x00, 0x00, 0x00, 0x00, 0x00, 0x0d, 0x00, 0x01];
665        let (is_text, _buf) = match detect_text_dump(&data[..]) {
666            Ok(result) => result,
667            Err(error) => panic!("text dump detection failed: {error}"),
668        };
669        assert!(!is_text);
670    }
671
672    #[test]
673    fn test_parse_header() {
674        let preamble = "\
675BGP table version is 1350657, local router ID is 45.112.180.132, vrf id 0
676Default local pref 100, local AS 3856
677Status codes:  s suppressed, d damped, h history, * valid, > best, = multipath,
678               i internal, r RIB-failure, S Stale, R Removed
679Nexthop codes: @NNN nexthop's vrf id, < announce-nh-self
680Origin codes:  i - IGP, e - EGP, ? - incomplete
681RPKI validation codes: V valid, I invalid, N Not found
682
683    Network          Next Hop            Metric LocPrf Weight Path
684";
685        let header = match parse_header(&mut preamble.as_bytes()) {
686            Ok(header) => header,
687            Err(error) => panic!("header parsing failed: {error}"),
688        };
689        let expected_router_id = match "45.112.180.132".parse() {
690            Ok(router_id) => router_id,
691            Err(error) => panic!("invalid expected router ID: {error}"),
692        };
693        assert_eq!(header.table_version, Some(1350657));
694        assert_eq!(header.router_id, Some(expected_router_id));
695        assert_eq!(header.local_as, Some(3856));
696        assert_eq!(header.column_positions, Some(TEST_COLUMNS));
697    }
698
699    #[test]
700    fn test_parse_route_line_basic() {
701        let line = fixed_width_route("1.0.0.0/24", "103.77.108.11", "0", "", "0", "13335 i");
702        let entry = parsed_route(&line);
703        assert_eq!(entry.prefix, "1.0.0.0/24");
704        assert_eq!(entry.next_hop, "103.77.108.11");
705        assert_eq!(entry.metric, Some(0));
706        assert_eq!(entry.local_pref, None);
707        assert_eq!(entry.origin, Some(Origin::IGP));
708        assert_eq!(entry.as_path, vec!["13335"]);
709    }
710
711    #[test]
712    fn test_parse_route_line_continuation() {
713        let line = fixed_width_route("", "103.77.108.118", "0", "", "0", "13335 i");
714        let entry = parsed_route(&line);
715        assert!(entry.prefix.is_empty());
716        assert_eq!(entry.next_hop, "103.77.108.118");
717        assert_eq!(entry.as_path, vec!["13335"]);
718    }
719
720    #[test]
721    fn test_parse_route_line_uses_fixed_width_attributes() {
722        let line = fixed_width_route("0.0.0.0/0", "103.77.108.116", "0", "", "0", "134942 4755 i");
723        let entry = parsed_route(&line);
724        assert_eq!(entry.metric, Some(0));
725        assert_eq!(entry.local_pref, None);
726        assert_eq!(entry.as_path, vec!["134942", "4755"]);
727    }
728
729    #[test]
730    fn test_parse_route_line_multi_asn() {
731        let line = fixed_width_route(
732            "1.0.0.0/24",
733            "103.77.108.116",
734            "10",
735            "200",
736            "0",
737            "134942 4755 i",
738        );
739        let entry = parsed_route(&line);
740        assert_eq!(entry.metric, Some(10));
741        assert_eq!(entry.local_pref, Some(200));
742        assert_eq!(entry.as_path, vec!["134942", "4755"]);
743        assert_eq!(entry.origin, Some(Origin::IGP));
744    }
745
746    #[test]
747    fn test_parse_route_line_origin_codes() {
748        let prefix = "1.0.0.0/24";
749        let next_hop = "10.0.0.1";
750        assert_eq!(
751            parsed_route(&fixed_width_route(prefix, next_hop, "0", "", "0", "100 i")).origin,
752            Some(Origin::IGP)
753        );
754        assert_eq!(
755            parsed_route(&fixed_width_route(prefix, next_hop, "0", "", "0", "100 e")).origin,
756            Some(Origin::EGP)
757        );
758        assert_eq!(
759            parsed_route(&fixed_width_route(prefix, next_hop, "0", "", "0", "100 ?")).origin,
760            Some(Origin::INCOMPLETE)
761        );
762    }
763
764    #[test]
765    fn test_parse_route_line_no_numeric_attrs() {
766        let line = fixed_width_route("1.0.0.0/24", "10.0.0.1", "", "", "", "100 i");
767        let entry = parsed_route(&line);
768        assert_eq!(entry.next_hop, "10.0.0.1");
769        assert_eq!(entry.as_path, vec!["100"]);
770        assert!(entry.metric.is_none());
771        assert!(entry.local_pref.is_none());
772    }
773
774    #[test]
775    fn test_parse_text_dump_preserves_continuation_prefix() {
776        let first = fixed_width_route("0.0.0.0/0", "103.77.108.116", "0", "", "0", "134942 4755 i");
777        let continuation = fixed_width_route("", "103.77.108.118", "0", "", "0", "13335 i");
778        let dump = format!(
779            "BGP table version is 1350657, local router ID is 45.112.180.132, vrf id 0\nDefault local pref 100, local AS 3856\n\n{TABLE_HEADER}\n{first}\n{continuation}\nDisplayed 1 routes and 2 total paths\n"
780        );
781        let elems = match parse_text_dump(dump.as_bytes()) {
782            Ok(elems) => elems,
783            Err(error) => panic!("text dump parsing failed: {error}"),
784        };
785        assert_eq!(elems.len(), 2);
786        assert_eq!(elems[0].prefix, elems[1].prefix);
787        assert_eq!(elems[0].med, Some(0));
788        assert_eq!(elems[0].local_pref, None);
789        assert_eq!(elems[0].origin_asns, Some(vec![Asn::from(4755u32)]));
790        assert_eq!(elems[1].origin_asns, Some(vec![Asn::from(13335u32)]));
791    }
792
793    #[test]
794    fn test_parse_text_dump_supports_wrapped_prefix() {
795        let dump = format!(
796            "BGP table version is 1350657, local router ID is 45.112.180.132, vrf id 0\nDefault local pref 100, local AS 3856\n\n{TABLE_HEADER}\n *  103.85.157.176/29\n                    103.77.108.116           0             0 134942 58715 152125 i\nDisplayed 1 routes and 1 total paths\n"
797        );
798        let elems = match parse_text_dump(dump.as_bytes()) {
799            Ok(elems) => elems,
800            Err(error) => panic!("text dump parsing failed: {error}"),
801        };
802        assert_eq!(elems.len(), 1);
803        assert_eq!(elems[0].origin_asns, Some(vec![Asn::from(152125u32)]));
804    }
805
806    #[test]
807    fn test_entry_to_elem() {
808        let entry = RouteEntry {
809            prefix: "1.0.0.0/24".into(),
810            next_hop: "103.77.108.11".into(),
811            metric: Some(0),
812            local_pref: Some(0),
813            as_path: vec!["13335".into()],
814            origin: Some(Origin::IGP),
815        };
816        let peer_ip = match "45.112.180.132".parse() {
817            Ok(peer_ip) => peer_ip,
818            Err(error) => panic!("invalid expected peer IP: {error}"),
819        };
820        let elem = match entry_to_elem(&entry, "1.0.0.0/24", peer_ip, 3856, 0.0) {
821            Some(elem) => elem,
822            None => panic!("BgpElem conversion failed"),
823        };
824        assert_eq!(elem.peer_ip.to_string(), "45.112.180.132");
825        assert_eq!(u32::from(elem.peer_asn), 3856);
826        assert_eq!(elem.origin, Some(Origin::IGP));
827        assert_eq!(elem.origin_asns, Some(vec![Asn::from(13335u32)]));
828    }
829
830    const ROUTE_VIEWS_HEADER: &str =
831        "   Network            Next Hop            Metric LocPrf Weight Path";
832
833    #[test]
834    fn test_detect_text_dump_route_views() {
835        let data = b"Status codes: s suppressed, d damped, h history, * valid, > best, i - internal,\n              r RIB-failure, S Stale\nOrigin codes: i - IGP, e - EGP, ? - incomplete\n";
836        let (is_text, _buf) = match detect_text_dump(&data[..]) {
837            Ok(result) => result,
838            Err(error) => panic!("text dump detection failed: {error}"),
839        };
840        assert!(is_text);
841    }
842
843    #[test]
844    fn test_parse_route_views_dump() {
845        let dump = format!(
846            "Status codes: s suppressed, d damped, h history, * valid, > best, i - internal,\n              r RIB-failure, S Stale\nOrigin codes: i - IGP, e - EGP, ? - incomplete\n\n{ROUTE_VIEWS_HEADER}\n*  0.0.0.0/0          147.28.0.3               0      0      0 3130 174 i\n*  1.0.0.0/24         12.0.1.63                0      0      0 7018 13335 i\n*  1.0.0.0/24         129.250.1.71          2001      0      0 2914 13335 i\n"
847        );
848        let elems = match parse_text_dump(dump.as_bytes()) {
849            Ok(elems) => elems,
850            Err(error) => panic!("route-views dump parsing failed: {error}"),
851        };
852        assert_eq!(elems.len(), 3);
853        // No router ID / local AS preamble: unspecified sentinel values.
854        assert_eq!(elems[0].peer_ip.to_string(), "0.0.0.0");
855        assert_eq!(u32::from(elems[0].peer_asn), 0);
856
857        assert_eq!(elems[0].prefix.prefix.to_string(), "0.0.0.0/0");
858        assert_eq!(elems[0].med, Some(0));
859        assert_eq!(elems[0].origin_asns, Some(vec![Asn::from(174u32)]));
860
861        assert_eq!(elems[2].prefix.prefix.to_string(), "1.0.0.0/24");
862        assert_eq!(elems[2].med, Some(2001));
863        assert_eq!(elems[2].origin, Some(Origin::IGP));
864        assert_eq!(elems[2].origin_asns, Some(vec![Asn::from(13335u32)]));
865    }
866
867    #[test]
868    fn test_infer_timestamp_route_views_path() {
869        let ts = infer_timestamp_from_path(
870            "https://archive.routeviews.org/oix-route-views/2026.07/oix-full-snapshot-2026-07-01-0000.bz2",
871        );
872        // 2026-07-01 00:00:00 UTC
873        assert_eq!(ts, Some(1782864000.0));
874    }
875
876    #[test]
877    fn test_infer_timestamp_pch_path() {
878        let ts = infer_timestamp_from_path(
879            "https://www.pch.net/resources/data/routing-tables/2026/2026.07/rib-ipv4.2026.07.01.gz",
880        );
881        // 2026-07-01 12:00:00 UTC (noon convention for date-only paths)
882        assert_eq!(ts, Some(1782907200.0));
883    }
884
885    #[test]
886    fn test_infer_timestamp_no_date() {
887        assert_eq!(infer_timestamp_from_path("/tmp/some-file.gz"), None);
888    }
889}