Skip to main content

bgpkit_parser/render/
text.rs

1//! Layered text rendering of MRT records.
2//!
3//! One record becomes one indented block: session context
4//! (`TIME`/`TYPE`/`PEER`/`LOCAL`), then the message body — `UPDATE:` sections
5//! for withdrawn/announced prefixes and every path attribute, `OPEN:`
6//! capabilities, session states, RIB entries, or the peer table. RFC 7606
7//! validation findings appear under `WARNINGS:` when present.
8//!
9//! The layout is our own (built from this crate's `Display` vocabulary);
10//! inspired by bgpdump's human-readable output, not byte-compatible with
11//! it.
12//!
13//! [`Style::ansi`] adds terminal colors for labels, section headers,
14//! prefixes, and next hops; the plain blocks are unchanged by it, since
15//! styling is a post-pass over the rendered text rather than part of it.
16
17use crate::models::*;
18
19const INDENT: &str = "  ";
20
21/// Render a record block with a trailing `HEX:` line carrying the raw
22/// bytes (e.g. from [`crate::render::hex::encode`]), so the block
23/// can be pasted straight into a byte-level dissector.
24pub fn format_record_with_hex(record: &MrtRecord, hex: &str) -> String {
25    let mut out = format_record(record);
26    out.push_str(&format!("{INDENT}HEX: {hex}\n"));
27    out
28}
29
30/// Render one MRT record as a layered text block.
31///
32/// Pure function of the record; see the [module docs](self) for scope and
33/// limitations (RIB peer indices, warning anchoring).
34pub fn format_record(record: &MrtRecord) -> String {
35    let mut out = String::with_capacity(512);
36    let ts = match record.common_header.microsecond_timestamp {
37        Some(us) => format!("{}.{:06}", record.common_header.timestamp, us),
38        None => record.common_header.timestamp.to_string(),
39    };
40    out.push_str(&format!("TIME: {ts}\n"));
41    out.push_str(&format!(
42        "TYPE: {:?}/{}\n",
43        record.common_header.entry_type,
44        entry_subtype_name(record)
45    ));
46
47    match &record.message {
48        MrtMessage::Bgp4Mp(msg) => render_bgp4mp(&mut out, msg),
49        MrtMessage::LegacyBgp(msg) => render_legacy_bgp(&mut out, msg),
50        MrtMessage::TableDumpMessage(msg) => render_table_dump(&mut out, msg),
51        MrtMessage::TableDumpMessageBatch(messages) => {
52            out.push_str(&format!("TABLE_DUMP: {} entries\n", messages.len()));
53            for msg in messages {
54                render_table_dump(&mut out, msg);
55            }
56        }
57        MrtMessage::TableDumpV2Message(msg) => render_table_dump_v2(&mut out, msg),
58    }
59    out
60}
61
62/// ANSI styling for the text format.
63///
64/// Accents are drawn from the basic ANSI set (bold where noted) instead of fixed RGB values, so
65/// they follow the reader's terminal theme and stay legible on light and dark backgrounds.
66#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
67pub struct Style {
68    ansi: bool,
69}
70
71impl Style {
72    /// Plain text, no escape sequences (the default).
73    pub const fn plain() -> Self {
74        Self { ansi: false }
75    }
76
77    /// ANSI SGR accents, for a terminal.
78    pub const fn ansi() -> Self {
79        Self { ansi: true }
80    }
81}
82
83const ANSI_LABEL: &str = "\x1b[1;34m";
84const ANSI_SECTION: &str = "\x1b[1;35m";
85const ANSI_PREFIX: &str = "\x1b[32m";
86const ANSI_NEXT_HOP: &str = "\x1b[33m";
87const ANSI_RESET: &str = "\x1b[0m";
88
89/// Headers whose indented lines are prefix lists.
90const PREFIX_SECTIONS: [&str; 3] = ["WITHDRAWN", "ANNOUNCED", "ANNOUNCED (labeled)"];
91/// Headers rendered in the section accent; every other `NAME: value` line is a property.
92const SECTION_LABELS: [&str; 17] = [
93    "UPDATE",
94    "OPEN",
95    "KEEPALIVE",
96    "NOTIFICATION",
97    "ROUTE_REFRESH",
98    "STATE_CHANGE",
99    "ATTRIBUTES",
100    "WARNINGS",
101    "CAPABILITIES",
102    "LINK_STATE_NLRI",
103    "FLOWSPEC_NLRI",
104    "TABLE_DUMP",
105    "PEER_INDEX_TABLE",
106    "GEO_PEER_TABLE",
107    "RIB_ENTRY",
108    "RIB_AFI",
109    "RIB_GENERIC",
110];
111/// Properties whose value is a next hop.
112const NEXT_HOP_LABELS: [&str; 2] = ["NEXT_HOP", "MP_REACH_NLRI"];
113/// Properties whose value is a prefix (table-dump RIB entries label it instead of listing it).
114const PREFIX_LABELS: [&str; 1] = ["PREFIX"];
115/// Properties rendered without a value.
116const BARE_LABELS: [&str; 1] = ["ATOMIC_AGGREGATE"];
117/// Property names that carry a space; matched by prefix because the type number varies.
118const SPACED_LABEL_PREFIXES: [&str; 3] =
119    ["RAW ATTRIBUTE (type", "DEPRECATED (type", "UNKNOWN (type"];
120
121/// Render one MRT record as a layered text block, styled for a terminal.
122///
123/// Styling is applied to the block [`format_record`] produces, so the text itself (and the
124/// unstyled output) is unaffected.
125pub fn format_record_with_style(record: &MrtRecord, style: Style) -> String {
126    let plain = format_record(record);
127    if style.ansi {
128        colorize(&plain)
129    } else {
130        plain
131    }
132}
133
134/// Render a record block with a trailing `HEX:` line (see [`format_record_with_hex`]),
135/// styled for a terminal.
136pub fn format_record_with_hex_and_style(record: &MrtRecord, hex: &str, style: Style) -> String {
137    let plain = format_record_with_hex(record, hex);
138    if style.ansi {
139        colorize(&plain)
140    } else {
141        plain
142    }
143}
144
145/// Accent the labels, section headers, prefixes and next hops of a plain block.
146///
147/// A line that matches nothing is copied through untouched, so a newly added line can only lose
148/// its accent, never gain broken output.
149fn colorize(plain: &str) -> String {
150    let mut out = String::with_capacity(plain.len() + plain.len() / 4);
151    let mut prefix_section = false;
152    for line in plain.split_inclusive('\n') {
153        let (content, newline) = match line.strip_suffix('\n') {
154            Some(content) => (content, "\n"),
155            None => (line, ""),
156        };
157        let trimmed = content.trim_start();
158        let indent = content.len() - trimmed.len();
159        let painted = if trimmed.is_empty() {
160            None
161        } else if indent >= 4 && prefix_section {
162            // a bare prefix, optionally followed by `labels=[...]` / `path-id` metadata;
163            // only the prefix itself is a route. (Colons inside IPv6 addresses rule out
164            // splitting on the label separator here.)
165            let (prefix, rest) = trimmed.split_once(' ').unwrap_or((trimmed, ""));
166            let rest = match rest.is_empty() {
167                true => String::new(),
168                false => format!(" {rest}"),
169            };
170            Some(format!("{ANSI_PREFIX}{prefix}{ANSI_RESET}{rest}"))
171        } else if let Some((label, value)) = trimmed.split_once(':') {
172            let label = label.trim_end();
173            let value = value.strip_prefix(' ').unwrap_or(value);
174            if value.is_empty() {
175                prefix_section = PREFIX_SECTIONS.contains(&label);
176                let accent = if prefix_section || SECTION_LABELS.contains(&label) {
177                    ANSI_SECTION
178                } else {
179                    ANSI_LABEL
180                };
181                Some(format!("{accent}{label}:{ANSI_RESET}"))
182            } else if SECTION_LABELS.contains(&label) {
183                // RIB_AFI/RIB_GENERIC summaries carry `PREFIX: <prefix>` inside the heading
184                Some(match value.split_once("PREFIX: ") {
185                    Some((head, tail)) => {
186                        let (prefix, rest) = tail.split_once(' ').unwrap_or((tail, ""));
187                        let rest = match rest.is_empty() {
188                            true => String::new(),
189                            false => format!(" {rest}"),
190                        };
191                        format!(
192                            "{ANSI_SECTION}{label}:{ANSI_RESET} {head}PREFIX: {ANSI_PREFIX}{prefix}{ANSI_RESET}{rest}"
193                        )
194                    }
195                    None => format!("{ANSI_SECTION}{label}:{ANSI_RESET} {value}"),
196                })
197            } else if PREFIX_LABELS.contains(&label) {
198                Some(format!(
199                    "{ANSI_LABEL}{label}:{ANSI_RESET} {ANSI_PREFIX}{value}{ANSI_RESET}"
200                ))
201            } else if NEXT_HOP_LABELS.contains(&label) {
202                // `MP_REACH_NLRI` renders `<afi>/<safi> next-hop <addr>`; only the address
203                // is a next hop, and a family without one stays plain. `NEXT_HOP` values are
204                // the address alone.
205                Some(match value.split_once(" next-hop ") {
206                    Some((family, address)) => format!(
207                        "{ANSI_LABEL}{label}:{ANSI_RESET} {family} next-hop {ANSI_NEXT_HOP}{address}{ANSI_RESET}"
208                    ),
209                    None if label == "NEXT_HOP" => {
210                        format!("{ANSI_LABEL}{label}:{ANSI_RESET} {ANSI_NEXT_HOP}{value}{ANSI_RESET}")
211                    }
212                    None => format!("{ANSI_LABEL}{label}:{ANSI_RESET} {value}"),
213                })
214            } else if !label.contains(' ')
215                || SPACED_LABEL_PREFIXES
216                    .iter()
217                    .any(|prefix| label.starts_with(prefix))
218            {
219                Some(format!("{ANSI_LABEL}{label}:{ANSI_RESET} {value}"))
220            } else {
221                // a sentence (e.g. a validation warning), not an identifier
222                None
223            }
224        } else if BARE_LABELS.contains(&trimmed) {
225            Some(format!("{ANSI_LABEL}{trimmed}{ANSI_RESET}"))
226        } else {
227            None
228        };
229        match painted {
230            Some(painted) => {
231                out.push_str(&" ".repeat(indent));
232                out.push_str(&painted);
233            }
234            None => out.push_str(content),
235        }
236        out.push_str(newline);
237    }
238    out
239}
240
241fn render_legacy_bgp(out: &mut String, msg: &LegacyBgp) {
242    // Deprecated MRT type-5 records (RFC 6396 Appendix B) carry the same
243    // endpoint metadata and BGP payloads as BGP4MP; render them fully.
244    match msg {
245        LegacyBgp::Message(m) => {
246            out.push_str(&format!(
247                "PEER: {} AS{}\nLOCAL: {} AS{}\n",
248                m.peer_ip, m.peer_asn, m.local_ip, m.local_asn
249            ));
250            render_bgp_message(out, &m.bgp_message);
251        }
252        LegacyBgp::StateChange(m) => {
253            out.push_str(&format!("PEER: {} AS{}\n", m.peer_ip, m.peer_asn));
254            out.push_str("STATE_CHANGE:\n");
255            out.push_str(&format!(
256                "{INDENT}OLD_STATE: {:?}\n{INDENT}NEW_STATE: {:?}\n",
257                m.old_state, m.new_state
258            ));
259        }
260    }
261}
262
263fn entry_subtype_name(record: &MrtRecord) -> String {
264    match &record.message {
265        MrtMessage::Bgp4Mp(msg) => format!("{:?}", msg.msg_type()),
266        _ => record.common_header.entry_subtype.to_string(),
267    }
268}
269
270fn render_bgp4mp(out: &mut String, msg: &Bgp4MpEnum) {
271    match msg {
272        Bgp4MpEnum::StateChange(m) => {
273            out.push_str(&format!(
274                "PEER: {} AS{}\nLOCAL: {} AS{}\n",
275                m.peer_ip, m.peer_asn, m.local_addr, m.local_asn
276            ));
277            out.push_str("STATE_CHANGE:\n");
278            out.push_str(&format!(
279                "{INDENT}OLD_STATE: {:?}\n{INDENT}NEW_STATE: {:?}\n",
280                m.old_state, m.new_state
281            ));
282        }
283        Bgp4MpEnum::Message(m) => {
284            out.push_str(&format!(
285                "PEER: {} AS{}\nLOCAL: {} AS{}\n",
286                m.peer_ip, m.peer_asn, m.local_ip, m.local_asn
287            ));
288            render_bgp_message(out, &m.bgp_message);
289        }
290    }
291}
292
293fn render_bgp_message(out: &mut String, msg: &BgpMessage) {
294    match msg {
295        BgpMessage::Open(open) => {
296            out.push_str("OPEN:\n");
297            out.push_str(&format!(
298                "{INDENT}VERSION: {}\n{INDENT}MY_AS: {}\n{INDENT}HOLD_TIME: {}s\n{INDENT}BGP_ID: {}\n",
299                open.version, open.asn, open.hold_time, open.bgp_identifier
300            ));
301            if open.opt_params.is_empty() {
302                return;
303            }
304            out.push_str(&format!("{INDENT}CAPABILITIES:\n"));
305            for param in &open.opt_params {
306                if let ParamValue::Capacities(caps) = &param.param_value {
307                    for cap in caps {
308                        out.push_str(&format!("{INDENT}{INDENT}{}\n", capability_summary(cap)));
309                    }
310                }
311            }
312        }
313        BgpMessage::Update(update) => {
314            out.push_str("UPDATE:\n");
315            render_update(out, update);
316        }
317        BgpMessage::Notification(n) => {
318            out.push_str("NOTIFICATION:\n");
319            out.push_str(&format!(
320                "{INDENT}ERROR: {:?} ({} bytes of data)\n",
321                n.error,
322                n.data.len()
323            ));
324        }
325        BgpMessage::KeepAlive => {
326            out.push_str("KEEPALIVE:\n");
327        }
328        BgpMessage::RouteRefresh(r) => {
329            out.push_str("ROUTE_REFRESH:\n");
330            out.push_str(&format!(
331                "{INDENT}AFI: {} SAFI: {} SUBTYPE: {}\n",
332                r.afi, r.safi, r.subtype
333            ));
334        }
335    }
336}
337
338fn capability_summary(cap: &Capability) -> String {
339    match &cap.value {
340        CapabilityValue::MultiprotocolExtensions(mp) => {
341            format!("{:?}: {:?}/{:?}", cap.ty, mp.afi, mp.safi)
342        }
343        CapabilityValue::FourOctetAs(foa) => format!("{:?}: AS{}", cap.ty, foa.asn),
344        CapabilityValue::AddPath(ap) => format!(
345            "{:?}: {} address families",
346            cap.ty,
347            ap.address_families.len()
348        ),
349        _ => format!("{:?}", cap.ty),
350    }
351}
352
353fn render_update(out: &mut String, update: &BgpUpdateMessage) {
354    // Withdrawn prefixes: the standard field plus any carried in
355    // MP_UNREACH_NLRI, so the transcript never silently drops routes.
356    // Labeled (MPLS) announcements, link-state NLRI, and FlowSpec rules
357    // live in their own MP collections and get dedicated sections.
358    let mut withdrawn: Vec<&NetworkPrefix> = update.withdrawn_prefixes.iter().collect();
359    let mut announced: Vec<&NetworkPrefix> = update.announced_prefixes.iter().collect();
360    let mut labeled_announced: Vec<&LabeledNetworkPrefix> = Vec::new();
361    let mut link_state_count = 0usize;
362    let mut flowspec_count = 0usize;
363    for attr in update.attributes.iter() {
364        match attr {
365            AttributeValue::MpUnreachNlri(nlri) => {
366                withdrawn.extend(nlri.prefixes.iter());
367                link_state_count += nlri.link_state_nlris.as_ref().map_or(0, Vec::len);
368                flowspec_count += nlri.flowspec_nlris.as_ref().map_or(0, Vec::len);
369            }
370            AttributeValue::MpReachNlri(nlri) => {
371                announced.extend(nlri.prefixes.iter());
372                labeled_announced.extend(nlri.labeled_prefixes.iter().flatten());
373                link_state_count += nlri.link_state_nlris.as_ref().map_or(0, Vec::len);
374                flowspec_count += nlri.flowspec_nlris.as_ref().map_or(0, Vec::len);
375            }
376            _ => {}
377        }
378    }
379
380    if !withdrawn.is_empty() {
381        out.push_str(&format!("{INDENT}WITHDRAWN:\n"));
382        for prefix in withdrawn {
383            out.push_str(&format!("{INDENT}{INDENT}{prefix}\n"));
384        }
385    }
386    if !announced.is_empty() {
387        out.push_str(&format!("{INDENT}ANNOUNCED:\n"));
388        for prefix in announced {
389            out.push_str(&format!("{INDENT}{INDENT}{prefix}\n"));
390        }
391    }
392    if !labeled_announced.is_empty() {
393        // RFC 3107/8277 labeled routes; withdrawals carry no labels and
394        // arrive through the plain prefix lists above.
395        out.push_str(&format!("{INDENT}ANNOUNCED (labeled):\n"));
396        for labeled in labeled_announced {
397            let labels: Vec<String> = labeled
398                .labels
399                .iter()
400                .map(|label| label.value().to_string())
401                .collect();
402            let path_id = labeled
403                .path_id
404                .map_or_else(String::new, |id| format!(" path-id {id}"));
405            out.push_str(&format!(
406                "{INDENT}{INDENT}{} labels=[{}]{path_id}\n",
407                labeled.prefix,
408                labels.join(", ")
409            ));
410        }
411    }
412    if link_state_count > 0 {
413        out.push_str(&format!(
414            "{INDENT}LINK_STATE_NLRI: {link_state_count} entries (BGP-LS)\n"
415        ));
416    }
417    if flowspec_count > 0 {
418        out.push_str(&format!(
419            "{INDENT}FLOWSPEC_NLRI: {flowspec_count} rules (RFC 8955)\n"
420        ));
421    }
422
423    let mut attrs = update.attributes.iter().peekable();
424    if attrs.peek().is_some() {
425        out.push_str(&format!("{INDENT}ATTRIBUTES:\n"));
426        for attr in attrs {
427            if let Some(line) = render_attribute(attr) {
428                out.push_str(&format!("{INDENT}{INDENT}{line}\n"));
429            }
430        }
431    }
432
433    let warnings = update.attributes.validation_warnings();
434    if !warnings.is_empty() {
435        out.push_str(&format!("{INDENT}WARNINGS:\n"));
436        for warning in warnings {
437            out.push_str(&format!("{INDENT}{INDENT}{warning}\n"));
438        }
439    }
440}
441
442/// One line per attribute. The MP prefix lists are folded into the
443/// WITHDRAWN/ANNOUNCED sections above; the attribute summary line (family,
444/// next hop) is retained here.
445fn render_attribute(value: &AttributeValue) -> Option<String> {
446    let line = match value {
447        AttributeValue::Origin(v) => format!("ORIGIN: {v}"),
448        AttributeValue::AsPath(v) => format!("AS_PATH: {v}"),
449        AttributeValue::As4Path(v) => format!("AS4_PATH: {v}"),
450        AttributeValue::NextHop(v) => format!("NEXT_HOP: {v}"),
451        AttributeValue::MultiExitDiscriminator(v) => format!("MULTI_EXIT_DISC: {v}"),
452        AttributeValue::LocalPreference(v) => format!("LOCAL_PREF: {v}"),
453        AttributeValue::OnlyToCustomer(v) => format!("ONLY_TO_CUSTOMER: {v}"),
454        AttributeValue::AtomicAggregate => "ATOMIC_AGGREGATE".to_string(),
455        AttributeValue::Aggregator { asn, id } => format!("AGGREGATOR: AS{asn} by {id}"),
456        AttributeValue::As4Aggregator { asn, id } => {
457            format!("AS4_AGGREGATOR: AS{asn} by {id}")
458        }
459        AttributeValue::Communities(v) => {
460            let rendered: Vec<String> = v.iter().map(|c| c.to_string()).collect();
461            format!("COMMUNITIES: {}", rendered.join(" "))
462        }
463        AttributeValue::LargeCommunities(v) => {
464            let rendered: Vec<String> = v.iter().map(|c| c.to_string()).collect();
465            format!("LARGE_COMMUNITIES: {}", rendered.join(" "))
466        }
467        AttributeValue::ExtendedCommunities(v) => {
468            let rendered: Vec<String> = v.iter().map(|c| c.to_string()).collect();
469            format!("EXTENDED_COMMUNITIES: {}", rendered.join(" "))
470        }
471        AttributeValue::Ipv6AddressSpecificExtendedCommunities(v) => {
472            let rendered: Vec<String> = v.iter().map(|c| c.to_string()).collect();
473            format!("IPV6_EXTENDED_COMMUNITIES: {}", rendered.join(" "))
474        }
475        AttributeValue::OriginatorId(v) => format!("ORIGINATOR_ID: {v}"),
476        AttributeValue::Clusters(v) => {
477            let rendered: Vec<String> = v.iter().map(|c| c.to_string()).collect();
478            format!("CLUSTER_LIST: {}", rendered.join(" "))
479        }
480        AttributeValue::MpReachNlri(nlri) => {
481            let next_hop = match &nlri.next_hop {
482                Some(nh) => format!(" next-hop {nh}"),
483                None => String::new(),
484            };
485            format!("MP_REACH_NLRI: {:?}/{:?}{next_hop}", nlri.afi, nlri.safi)
486        }
487        AttributeValue::MpUnreachNlri(nlri) => {
488            format!("MP_UNREACH_NLRI: {:?}/{:?}", nlri.afi, nlri.safi)
489        }
490        AttributeValue::Aigp(v) => format!("AIGP: {v:?}"),
491        AttributeValue::BfdDiscriminator(v) => format!("BFD_DISCRIMINATOR: {v:?}"),
492        AttributeValue::DomainPath(v) => format!("BGP_DOMAIN_PATH: {v:?}"),
493        AttributeValue::TrafficEngineering(v) => format!("TRAFFIC_ENGINEERING: {v:?}"),
494        AttributeValue::TunnelEncapsulation(v) => format!("TUNNEL_ENCAPSULATION: {v:?}"),
495        AttributeValue::LinkState(v) => format!("BGP_LS: {v:?}"),
496        AttributeValue::BgpPrefixSid(v) => format!("BGP_PREFIX_SID: {v:?}"),
497        AttributeValue::Bier(v) => format!("BIER: {v:?}"),
498        AttributeValue::Sfp(v) => format!("SFP: {v:?}"),
499        AttributeValue::AttrSet(v) => {
500            format!("ATTR_SET: attributes of AS{}", v.origin_as)
501        }
502        AttributeValue::Development(v) => format!("DEVELOPMENT: {} bytes", v.len()),
503        AttributeValue::Raw(v) => {
504            format!("RAW ATTRIBUTE (type {}): {} bytes", v.code, v.bytes.len())
505        }
506        AttributeValue::Deprecated(v) => {
507            format!("DEPRECATED (type {}): {} bytes", v.code, v.bytes.len())
508        }
509        AttributeValue::Unknown(v) => {
510            format!("UNKNOWN (type {}): {} bytes", v.code, v.bytes.len())
511        }
512    };
513    Some(line)
514}
515
516fn render_table_dump(out: &mut String, msg: &TableDumpMessage) {
517    out.push_str(&format!(
518        "RIB_ENTRY:\n{INDENT}PREFIX: {}\n{INDENT}PEER: {} AS{}\n{INDENT}ORIGINATED: {}\n",
519        msg.prefix, msg.peer_ip, msg.peer_asn, msg.originated_time
520    ));
521    render_attributes_block(out, &msg.attributes, 1);
522}
523
524fn render_table_dump_v2(out: &mut String, msg: &TableDumpV2Message) {
525    match msg {
526        TableDumpV2Message::PeerIndexTable(pit) => {
527            out.push_str(&format!(
528                "PEER_INDEX_TABLE: {} peers\n",
529                pit.id_peer_map.len()
530            ));
531            // Peer order follows the collector's index assignment.
532            let mut peers: Vec<_> = pit.id_peer_map.iter().collect();
533            peers.sort_by_key(|(id, _)| **id);
534            for (id, peer) in peers {
535                out.push_str(&format!(
536                    "{INDENT}PEER[{id}]: {} AS{}\n",
537                    peer.peer_ip, peer.peer_asn
538                ));
539            }
540        }
541        TableDumpV2Message::RibAfi(rib) => {
542            out.push_str(&format!(
543                "RIB_AFI: {:?} PREFIX: {} ({} entries)\n",
544                rib.rib_type,
545                rib.prefix,
546                rib.rib_entries.len()
547            ));
548            for entry in &rib.rib_entries {
549                out.push_str(&format!(
550                    "{INDENT}RIB_ENTRY:\n{INDENT}{INDENT}PEER_INDEX: {}\n{INDENT}{INDENT}ORIGINATED: {}\n",
551                    entry.peer_index, entry.originated_time
552                ));
553                render_attributes_block(out, &entry.attributes, 2);
554            }
555        }
556        TableDumpV2Message::RibGeneric(rib) => {
557            out.push_str(&format!(
558                "RIB_GENERIC: {:?}/{:?} PREFIX: {} ({} entries)\n",
559                rib.afi,
560                rib.safi,
561                rib.nlri,
562                rib.rib_entries.len()
563            ));
564            for entry in &rib.rib_entries {
565                out.push_str(&format!(
566                    "{INDENT}RIB_ENTRY:\n{INDENT}{INDENT}PEER_INDEX: {}\n{INDENT}{INDENT}ORIGINATED: {}\n",
567                    entry.peer_index, entry.originated_time
568                ));
569                render_attributes_block(out, &entry.attributes, 2);
570            }
571        }
572        TableDumpV2Message::GeoPeerTable(gpt) => {
573            out.push_str(&format!("GEO_PEER_TABLE: {} peers\n", gpt.geo_peers.len()));
574        }
575    }
576}
577
578fn render_attributes_block(out: &mut String, attributes: &Attributes, depth: usize) {
579    let pad = INDENT.repeat(depth + 1);
580    let mut iter = attributes.iter().peekable();
581    // An empty attribute list can still carry validation findings (RIB
582    // entries run check_mandatory_attributes during parsing), so the
583    // warnings section renders regardless of the attribute count.
584    if iter.peek().is_some() {
585        out.push_str(&format!("{}ATTRIBUTES:\n", INDENT.repeat(depth)));
586    }
587    for attr in iter {
588        if let Some(line) = render_attribute(attr) {
589            out.push_str(&format!("{pad}{line}\n"));
590        }
591    }
592    let warnings = attributes.validation_warnings();
593    if !warnings.is_empty() {
594        out.push_str(&format!("{}WARNINGS:\n", INDENT.repeat(depth)));
595        for warning in warnings {
596            out.push_str(&format!("{pad}{warning}\n"));
597        }
598    }
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604    use crate::error::BgpValidationWarning;
605    use crate::models::capabilities::{
606        BgpCapabilityType, FourOctetAsCapability, MultiprotocolExtensionsCapability,
607    };
608    use std::net::IpAddr;
609    use std::net::Ipv4Addr;
610    use std::str::FromStr;
611
612    fn update_record(attributes: Attributes) -> MrtRecord {
613        MrtRecord {
614            common_header: CommonHeader {
615                timestamp: 1_666_542_810,
616                microsecond_timestamp: Some(970_000),
617                entry_type: EntryType::BGP4MP,
618                entry_subtype: Bgp4MpType::MessageAs4 as u16,
619                length: 0,
620            },
621            message: MrtMessage::Bgp4Mp(Bgp4MpEnum::Message(Bgp4MpMessage {
622                msg_type: Bgp4MpType::MessageAs4,
623                peer_asn: Asn::new_32bit(64496),
624                local_asn: Asn::new_32bit(64497),
625                interface_index: 0,
626                peer_ip: IpAddr::from_str("192.0.2.1").unwrap(),
627                local_ip: IpAddr::from_str("192.0.2.2").unwrap(),
628                bgp_message: BgpMessage::Update(BgpUpdateMessage {
629                    withdrawn_prefixes: vec![NetworkPrefix::from_str("203.0.113.0/24").unwrap()],
630                    attributes,
631                    announced_prefixes: vec![NetworkPrefix::from_str("198.51.100.0/24").unwrap()],
632                }),
633            })),
634        }
635    }
636
637    fn full_attributes() -> Attributes {
638        let mut attributes = Attributes::default();
639        attributes.add_attr(AttributeValue::Origin(Origin::IGP).into());
640        attributes.add_attr(
641            AttributeValue::AsPath(AsPath::from_segments(vec![
642                AsPathSegment::sequence([65001, 65002]),
643                AsPathSegment::set([65010, 65011]),
644            ]))
645            .into(),
646        );
647        attributes
648            .add_attr(AttributeValue::NextHop(IpAddr::from_str("192.0.2.254").unwrap()).into());
649        attributes.add_attr(AttributeValue::LocalPreference(100).into());
650        attributes.add_attr(AttributeValue::OnlyToCustomer(64512.into()).into());
651        attributes.add_attr(
652            AttributeValue::Communities(vec![
653                Community::Custom(Asn::new_32bit(64512), 100),
654                Community::NoExport,
655            ])
656            .into(),
657        );
658        attributes.add_attr(
659            AttributeValue::LargeCommunities(vec![LargeCommunity::new(64496, [1, 2])]).into(),
660        );
661        attributes
662    }
663
664    #[test]
665    fn renders_hex_line_variant() {
666        let text = format_record_with_hex(&update_record(full_attributes()), "deadbeef");
667        assert!(text.trim_end().ends_with("  HEX: deadbeef"));
668        // everything before the HEX line is the plain block
669        let plain = format_record(&update_record(full_attributes()));
670        assert!(text.starts_with(&plain));
671    }
672
673    #[test]
674    fn renders_layered_update_block() {
675        let text = format_record(&update_record(full_attributes()));
676        let expected = "\
677TIME: 1666542810.970000
678TYPE: BGP4MP/MessageAs4
679PEER: 192.0.2.1 AS64496
680LOCAL: 192.0.2.2 AS64497
681UPDATE:
682  WITHDRAWN:
683    203.0.113.0/24
684  ANNOUNCED:
685    198.51.100.0/24
686  ATTRIBUTES:
687    ORIGIN: IGP
688    AS_PATH: 65001 65002 {65010,65011}
689    NEXT_HOP: 192.0.2.254
690    LOCAL_PREF: 100
691    ONLY_TO_CUSTOMER: 64512
692    COMMUNITIES: 64512:100 no-export
693    LARGE_COMMUNITIES: 64496:1:2
694";
695        assert_eq!(text, expected);
696    }
697
698    #[test]
699    fn renders_warnings_section() {
700        let mut attributes = full_attributes();
701        attributes.add_validation_warning(BgpValidationWarning::DuplicateAttribute {
702            attr_type: AttrType::ORIGIN,
703        });
704        let text = format_record(&update_record(attributes));
705        assert!(text.contains("  WARNINGS:\n"));
706        assert!(text.contains("Duplicate attribute: ORIGIN"));
707        // the warning follows the attributes section
708        let attr_pos = text.find("LARGE_COMMUNITIES").unwrap();
709        let warn_pos = text.find("WARNINGS").unwrap();
710        assert!(warn_pos > attr_pos);
711    }
712
713    #[test]
714    fn renders_mp_reach_prefixes_in_sections() {
715        let mut attributes = Attributes::default();
716        attributes.add_attr(AttributeValue::Origin(Origin::IGP).into());
717        attributes.add_attr(
718            AttributeValue::MpReachNlri(Nlri {
719                afi: Afi::Ipv6,
720                safi: Safi::Unicast,
721                next_hop: Some(NextHopAddress::Ipv6("2001:db8::1".parse().unwrap())),
722                prefixes: vec![NetworkPrefix::from_str("2001:db8::/32").unwrap()],
723                labeled_prefixes: None,
724                link_state_nlris: None,
725                flowspec_nlris: None,
726            })
727            .into(),
728        );
729        let text = format_record(&update_record(attributes));
730        assert!(text.contains("    198.51.100.0/24\n    2001:db8::/32\n"));
731        assert!(text.contains("MP_REACH_NLRI: Ipv6/Unicast next-hop 2001:db8::1"));
732    }
733
734    /// Strip ANSI SGR sequences, to compare styled output with its plain form.
735    fn strip_sgr(text: &str) -> String {
736        let mut out = String::with_capacity(text.len());
737        let mut rest = text;
738        while let Some(start) = rest.find('\x1b') {
739            out.push_str(&rest[..start]);
740            match rest[start..].find('m') {
741                Some(end) => rest = &rest[start + end + 1..],
742                None => return out,
743            }
744        }
745        out.push_str(rest);
746        out
747    }
748
749    #[test]
750    fn style_plain_is_byte_identical() {
751        let record = update_record(full_attributes());
752        assert_eq!(
753            format_record_with_style(&record, Style::plain()),
754            format_record(&record)
755        );
756        assert_eq!(
757            format_record_with_hex_and_style(&record, "deadbeef", Style::plain()),
758            format_record_with_hex(&record, "deadbeef")
759        );
760    }
761
762    #[test]
763    fn style_accents_labels_sections_prefixes_and_next_hops() {
764        let mut attributes = full_attributes();
765        attributes.add_attr(
766            AttributeValue::MpReachNlri(Nlri {
767                afi: Afi::Ipv6,
768                safi: Safi::Unicast,
769                // a comma-joined RFC 2545 pair, as RIS Live projects it
770                next_hop: Some(NextHopAddress::Ipv6LinkLocal(
771                    "2001:db8::1".parse().unwrap(),
772                    "fe80::1".parse().unwrap(),
773                )),
774                prefixes: vec![NetworkPrefix::from_str("2001:db8:1::/48").unwrap()],
775                labeled_prefixes: None,
776                link_state_nlris: None,
777                flowspec_nlris: None,
778            })
779            .into(),
780        );
781        let record = update_record(attributes);
782        let plain = format_record(&record);
783        let styled = format_record_with_style(&record, Style::ansi());
784
785        // styling wraps spans, it never rewrites the text
786        assert_ne!(styled, plain);
787        assert_eq!(strip_sgr(&styled), plain);
788
789        assert!(styled.contains(&format!("{ANSI_LABEL}TIME:{ANSI_RESET} 1666542810.970000")));
790        assert!(styled.contains(&format!("{ANSI_LABEL}PEER:{ANSI_RESET} 192.0.2.1 AS64496")));
791        assert!(styled.contains(&format!("{ANSI_SECTION}UPDATE:{ANSI_RESET}")));
792        assert!(styled.contains(&format!("{ANSI_SECTION}ANNOUNCED:{ANSI_RESET}")));
793        assert!(styled.contains(&format!("{ANSI_SECTION}ATTRIBUTES:{ANSI_RESET}")));
794        assert!(styled.contains(&format!("    {ANSI_PREFIX}198.51.100.0/24{ANSI_RESET}")));
795        assert!(styled.contains(&format!("{ANSI_LABEL}ORIGIN:{ANSI_RESET} IGP")));
796        assert!(styled.contains(&format!(
797            "{ANSI_LABEL}MP_REACH_NLRI:{ANSI_RESET} Ipv6/Unicast next-hop {ANSI_NEXT_HOP}2001:db8::1,fe80::1{ANSI_RESET}"
798        )));
799    }
800
801    #[test]
802    fn style_accents_table_dump_prefixes() {
803        let mut attributes = Attributes::default();
804        attributes.add_attr(AttributeValue::Origin(Origin::IGP).into());
805        let rib = MrtRecord {
806            common_header: CommonHeader {
807                timestamp: 6,
808                microsecond_timestamp: None,
809                entry_type: EntryType::TABLE_DUMP_V2,
810                entry_subtype: 2,
811                length: 0,
812            },
813            message: MrtMessage::TableDumpV2Message(TableDumpV2Message::RibAfi(RibAfiEntries {
814                rib_type: TableDumpV2Type::RibIpv4Unicast,
815                sequence_number: 1,
816                prefix: NetworkPrefix::from_str("198.51.100.0/24").unwrap(),
817                rib_entries: vec![RibEntry {
818                    peer_index: 0,
819                    originated_time: 1_666_542_000,
820                    path_id: None,
821                    attributes,
822                }],
823            })),
824        };
825        let styled = format_record_with_style(&rib, Style::ansi());
826        // the RIB_AFI summary embeds the prefix in its heading line
827        assert!(styled.contains(&format!(
828            "PREFIX: {ANSI_PREFIX}198.51.100.0/24{ANSI_RESET} (1 entries)"
829        )));
830
831        // a legacy type-5 RIB entry labels the prefix instead of listing it
832        let entry = MrtRecord {
833            common_header: CommonHeader {
834                timestamp: 6,
835                microsecond_timestamp: None,
836                entry_type: EntryType::TABLE_DUMP,
837                entry_subtype: 12,
838                length: 0,
839            },
840            message: MrtMessage::TableDumpMessage(TableDumpMessage {
841                view_number: 0,
842                sequence_number: 1,
843                prefix: NetworkPrefix::from_str("198.51.100.0/24").unwrap(),
844                status: 1,
845                originated_time: 1_666_542_000,
846                peer_ip: IpAddr::from_str("192.0.2.1").unwrap(),
847                peer_asn: Asn::new_16bit(64496),
848                attributes: Attributes::default(),
849            }),
850        };
851        let styled = format_record_with_style(&entry, Style::ansi());
852        assert!(styled.contains(&format!(
853            "{ANSI_LABEL}PREFIX:{ANSI_RESET} {ANSI_PREFIX}198.51.100.0/24{ANSI_RESET}\n"
854        )));
855    }
856
857    #[test]
858    fn style_accents_bare_and_spaced_property_labels() {
859        let plain = "UPDATE:\n  ATTRIBUTES:\n    ATOMIC_AGGREGATE\n    RAW ATTRIBUTE (type 99): 4 bytes\n    Duplicate attribute: ORIGIN\n";
860        let styled = colorize(plain);
861        assert!(styled.contains(&format!("{ANSI_LABEL}ATOMIC_AGGREGATE{ANSI_RESET}")));
862        assert!(styled.contains(&format!(
863            "{ANSI_LABEL}RAW ATTRIBUTE (type 99):{ANSI_RESET} 4 bytes"
864        )));
865        // validation warnings are prose, not identifiers: still untouched
866        assert!(styled.contains("    Duplicate attribute: ORIGIN\n"));
867        assert_eq!(strip_sgr(&styled), plain);
868    }
869
870    #[test]
871    fn style_accents_only_the_prefix_in_labeled_entries() {
872        let plain = "UPDATE:\n  ANNOUNCED (labeled):\n    2001:db8::/32 labels=[16] path-id 3\n";
873        let styled = colorize(plain);
874        assert!(styled.contains(&format!(
875            "    {ANSI_PREFIX}2001:db8::/32{ANSI_RESET} labels=[16] path-id 3"
876        )));
877        assert_eq!(strip_sgr(&styled), plain);
878    }
879
880    #[test]
881    fn style_leaves_sentence_lines_plain() {
882        let mut attributes = full_attributes();
883        attributes.add_validation_warning(BgpValidationWarning::DuplicateAttribute {
884            attr_type: AttrType::ORIGIN,
885        });
886        let styled = format_record_with_style(&update_record(attributes), Style::ansi());
887        // a warning is a sentence, not an identifier: no accent, no escape sequences
888        assert!(styled.contains("    Duplicate attribute: ORIGIN\n"));
889    }
890
891    #[test]
892    fn renders_state_change_open_and_keepalive() {
893        let state = MrtRecord {
894            common_header: CommonHeader {
895                timestamp: 2,
896                microsecond_timestamp: None,
897                entry_type: EntryType::BGP4MP,
898                entry_subtype: Bgp4MpType::StateChange as u16,
899                length: 0,
900            },
901            message: MrtMessage::Bgp4Mp(Bgp4MpEnum::StateChange(Bgp4MpStateChange {
902                msg_type: Bgp4MpType::StateChange,
903                peer_asn: Asn::new_16bit(64496),
904                local_asn: Asn::new_16bit(64497),
905                interface_index: 0,
906                peer_ip: IpAddr::from_str("192.0.2.1").unwrap(),
907                local_addr: IpAddr::from_str("192.0.2.2").unwrap(),
908                old_state: BgpState::Idle,
909                new_state: BgpState::Established,
910            })),
911        };
912        let text = format_record(&state);
913        assert!(text.contains("PEER: 192.0.2.1 AS64496"));
914        assert!(text.contains("LOCAL: 192.0.2.2 AS64497"));
915        assert!(text.contains("STATE_CHANGE:"));
916        assert!(text.contains("OLD_STATE: Idle"));
917        assert!(text.contains("NEW_STATE: Established"));
918
919        let keepalive = MrtRecord {
920            common_header: CommonHeader {
921                timestamp: 3,
922                microsecond_timestamp: None,
923                entry_type: EntryType::BGP4MP,
924                entry_subtype: Bgp4MpType::MessageAs4 as u16,
925                length: 0,
926            },
927            message: MrtMessage::Bgp4Mp(Bgp4MpEnum::Message(Bgp4MpMessage {
928                msg_type: Bgp4MpType::MessageAs4,
929                peer_asn: Asn::new_32bit(64496),
930                local_asn: Asn::new_32bit(64497),
931                interface_index: 0,
932                peer_ip: IpAddr::from_str("192.0.2.1").unwrap(),
933                local_ip: IpAddr::from_str("192.0.2.2").unwrap(),
934                bgp_message: BgpMessage::KeepAlive,
935            })),
936        };
937        assert!(format_record(&keepalive).contains("KEEPALIVE:"));
938
939        let open = MrtRecord {
940            common_header: CommonHeader {
941                timestamp: 4,
942                microsecond_timestamp: None,
943                entry_type: EntryType::BGP4MP,
944                entry_subtype: Bgp4MpType::MessageAs4 as u16,
945                length: 0,
946            },
947            message: MrtMessage::Bgp4Mp(Bgp4MpEnum::Message(Bgp4MpMessage {
948                msg_type: Bgp4MpType::MessageAs4,
949                peer_asn: Asn::new_32bit(64496),
950                local_asn: Asn::new_32bit(64497),
951                interface_index: 0,
952                peer_ip: IpAddr::from_str("192.0.2.1").unwrap(),
953                local_ip: IpAddr::from_str("192.0.2.2").unwrap(),
954                bgp_message: BgpMessage::Open(BgpOpenMessage {
955                    version: 4,
956                    asn: Asn::new_16bit(64496),
957                    hold_time: 180,
958                    bgp_identifier: Ipv4Addr::new(192, 0, 2, 1),
959                    extended_length: false,
960                    opt_params: vec![OptParam {
961                        param_type: 2,
962                        param_value: ParamValue::Capacities(vec![
963                            Capability {
964                                ty: BgpCapabilityType::MULTIPROTOCOL_EXTENSIONS_FOR_BGP_4,
965                                value: CapabilityValue::MultiprotocolExtensions(
966                                    MultiprotocolExtensionsCapability::new(
967                                        Afi::Ipv4,
968                                        Safi::Unicast,
969                                    ),
970                                ),
971                            },
972                            Capability {
973                                ty: BgpCapabilityType::SUPPORT_FOR_4_OCTET_AS_NUMBER_CAPABILITY,
974                                value: CapabilityValue::FourOctetAs(FourOctetAsCapability::new(
975                                    64496,
976                                )),
977                            },
978                        ]),
979                    }],
980                }),
981            })),
982        };
983        let text = format_record(&open);
984        assert!(text.contains("OPEN:"));
985        assert!(text.contains("MY_AS: 64496"));
986        assert!(text.contains("MULTIPROTOCOL_EXTENSIONS_FOR_BGP_4: Ipv4/Unicast"));
987        assert!(text.contains("SUPPORT_FOR_4_OCTET_AS_NUMBER_CAPABILITY: AS64496"));
988    }
989
990    #[test]
991    fn renders_labeled_linkstate_and_flowspec_sections() {
992        use crate::models::{LabeledNetworkPrefix, MplsLabel};
993
994        let labeled = LabeledNetworkPrefix {
995            prefix: "192.0.2.0/24".parse().unwrap(),
996            labels: smallvec::SmallVec::from_vec(vec![
997                MplsLabel::try_new(24001).unwrap(),
998                MplsLabel::try_new(16).unwrap(),
999            ]),
1000            path_id: Some(7),
1001        };
1002        let mut attributes = Attributes::default();
1003        attributes.add_attr(AttributeValue::Origin(Origin::IGP).into());
1004        attributes.add_attr(
1005            AttributeValue::MpReachNlri(Nlri {
1006                afi: Afi::Ipv4,
1007                safi: Safi::MplsLabel,
1008                next_hop: Some(NextHopAddress::Ipv4("192.0.2.254".parse().unwrap())),
1009                prefixes: vec![],
1010                labeled_prefixes: Some(vec![labeled]),
1011                link_state_nlris: Some(vec![]),
1012                flowspec_nlris: Some(vec![]),
1013            })
1014            .into(),
1015        );
1016        let text = format_record(&update_record(attributes));
1017        assert!(text.contains("  ANNOUNCED (labeled):\n"));
1018        assert!(text.contains("    192.0.2.0/24 labels=[24001, 16] path-id 7\n"));
1019        // empty link-state/flowspec collections produce no sections
1020        assert!(!text.contains("LINK_STATE_NLRI"));
1021        assert!(!text.contains("FLOWSPEC_NLRI"));
1022    }
1023
1024    #[test]
1025    fn renders_rib_warnings_with_empty_attributes() {
1026        // RIB parsers run check_mandatory_attributes during parsing, so an
1027        // entry can reach the renderer with an empty attribute list but
1028        // non-empty findings — the warnings section must render without an
1029        // ATTRIBUTES section. (Warnings are attached here directly because
1030        // the record is built structurally, not parsed from the wire.)
1031        let mut empty_with_warnings = Attributes::default();
1032        empty_with_warnings.add_validation_warning(
1033            BgpValidationWarning::MissingWellKnownAttribute {
1034                attr_type: AttrType::ORIGIN,
1035            },
1036        );
1037        let rib = MrtRecord {
1038            common_header: CommonHeader {
1039                timestamp: 9,
1040                microsecond_timestamp: None,
1041                entry_type: EntryType::TABLE_DUMP_V2,
1042                entry_subtype: 2,
1043                length: 0,
1044            },
1045            message: MrtMessage::TableDumpV2Message(TableDumpV2Message::RibAfi(RibAfiEntries {
1046                rib_type: TableDumpV2Type::RibIpv4Unicast,
1047                sequence_number: 1,
1048                prefix: NetworkPrefix::from_str("198.51.100.0/24").unwrap(),
1049                rib_entries: vec![RibEntry {
1050                    peer_index: 0,
1051                    originated_time: 1,
1052                    path_id: None,
1053                    attributes: empty_with_warnings,
1054                }],
1055            })),
1056        };
1057        let text = format_record(&rib);
1058        assert!(text.contains("WARNINGS:"));
1059        assert!(text.contains("Missing well-known mandatory attribute: ORIGIN"));
1060        assert!(!text.contains("ATTRIBUTES:"));
1061    }
1062
1063    #[test]
1064    fn renders_legacy_bgp_records() {
1065        let legacy = MrtRecord {
1066            common_header: CommonHeader {
1067                timestamp: 7,
1068                microsecond_timestamp: None,
1069                entry_type: EntryType::BGP,
1070                entry_subtype: 5,
1071                length: 0,
1072            },
1073            message: MrtMessage::LegacyBgp(LegacyBgp::Message(LegacyBgpMessage {
1074                peer_asn: Asn::new_16bit(5409),
1075                peer_ip: IpAddr::from_str("195.211.222.254").unwrap(),
1076                local_asn: Asn::new_16bit(12654),
1077                local_ip: IpAddr::from_str("193.0.0.1").unwrap(),
1078                bgp_message: BgpMessage::KeepAlive,
1079            })),
1080        };
1081        let text = format_record(&legacy);
1082        assert!(text.contains("PEER: 195.211.222.254 AS5409"));
1083        assert!(text.contains("LOCAL: 193.0.0.1 AS12654"));
1084        assert!(text.contains("KEEPALIVE:"));
1085
1086        let state = MrtRecord {
1087            common_header: CommonHeader {
1088                timestamp: 8,
1089                microsecond_timestamp: None,
1090                entry_type: EntryType::BGP,
1091                entry_subtype: 6,
1092                length: 0,
1093            },
1094            message: MrtMessage::LegacyBgp(LegacyBgp::StateChange(LegacyBgpStateChange {
1095                peer_asn: Asn::new_16bit(5409),
1096                peer_ip: IpAddr::from_str("195.211.222.254").unwrap(),
1097                old_state: BgpState::Established,
1098                new_state: BgpState::Idle,
1099            })),
1100        };
1101        let text = format_record(&state);
1102        assert!(text.contains("PEER: 195.211.222.254 AS5409"));
1103        // a legacy state change carries only the peer endpoint
1104        assert!(!text.contains("LOCAL:"));
1105        assert!(text.contains("STATE_CHANGE:"));
1106        assert!(text.contains("OLD_STATE: Established"));
1107        assert!(text.contains("NEW_STATE: Idle"));
1108    }
1109
1110    #[test]
1111    fn renders_peer_index_table_and_rib_entries() {
1112        let mut peers = std::collections::HashMap::new();
1113        peers.insert(
1114            0u16,
1115            Peer::new(
1116                Ipv4Addr::new(10, 0, 0, 1),
1117                IpAddr::from_str("192.0.2.1").unwrap(),
1118                Asn::new_32bit(64496),
1119            ),
1120        );
1121        let pit = MrtRecord {
1122            common_header: CommonHeader {
1123                timestamp: 5,
1124                microsecond_timestamp: None,
1125                entry_type: EntryType::TABLE_DUMP_V2,
1126                entry_subtype: 1,
1127                length: 0,
1128            },
1129            message: MrtMessage::TableDumpV2Message(TableDumpV2Message::PeerIndexTable(
1130                PeerIndexTable {
1131                    collector_bgp_id: Ipv4Addr::new(10, 0, 0, 254),
1132                    view_name: "".to_string(),
1133                    id_peer_map: peers,
1134                    peer_ip_id_map: std::collections::HashMap::new(),
1135                },
1136            )),
1137        };
1138        let text = format_record(&pit);
1139        assert!(text.contains("PEER_INDEX_TABLE: 1 peers"));
1140        assert!(text.contains("PEER[0]: 192.0.2.1 AS64496"));
1141
1142        let mut attributes = Attributes::default();
1143        attributes.add_attr(AttributeValue::Origin(Origin::IGP).into());
1144        attributes.add_attr(AttributeValue::AsPath(AsPath::from_sequence([64500])).into());
1145        let rib = MrtRecord {
1146            common_header: CommonHeader {
1147                timestamp: 6,
1148                microsecond_timestamp: None,
1149                entry_type: EntryType::TABLE_DUMP_V2,
1150                entry_subtype: 2,
1151                length: 0,
1152            },
1153            message: MrtMessage::TableDumpV2Message(TableDumpV2Message::RibAfi(RibAfiEntries {
1154                rib_type: TableDumpV2Type::RibIpv4Unicast,
1155                sequence_number: 1,
1156                prefix: NetworkPrefix::from_str("198.51.100.0/24").unwrap(),
1157                rib_entries: vec![RibEntry {
1158                    peer_index: 0,
1159                    originated_time: 1_666_542_000,
1160                    path_id: None,
1161                    attributes,
1162                }],
1163            })),
1164        };
1165        let text = format_record(&rib);
1166        assert!(text.contains("PREFIX: 198.51.100.0/24 (1 entries)"));
1167        assert!(text.contains("PEER_INDEX: 0"));
1168        assert!(text.contains("AS_PATH: 64500"));
1169    }
1170}