async_snmp/format/
display_hint.rs

1//! RFC 2579 DISPLAY-HINT formatting for OCTET STRING values.
2//!
3//! This module provides parsing and application of DISPLAY-HINT format strings
4//! to raw bytes, commonly used to format MAC addresses, IP addresses, and other
5//! structured binary data.
6//!
7//! # Examples
8//!
9//! ```
10//! use async_snmp::format::display_hint;
11//!
12//! // IPv4 address
13//! assert_eq!(display_hint::apply("1d.1d.1d.1d", &[192, 168, 1, 1]), "192.168.1.1");
14//!
15//! // MAC address (implicit repetition)
16//! assert_eq!(display_hint::apply("1x:", &[0x00, 0x1a, 0x2b, 0x3c, 0x4d, 0x5e]), "00:1a:2b:3c:4d:5e");
17//!
18//! // DateAndTime
19//! assert_eq!(display_hint::apply("2d-1d-1d,1d:1d:1d.1d", &[0x07, 0xE6, 8, 15, 8, 1, 15, 0]), "2022-8-15,8:1:15.0");
20//! ```
21
22use std::fmt::Write;
23
24/// Apply RFC 2579 DISPLAY-HINT formatting to raw bytes.
25///
26/// Parses the hint string and applies it to the data in a single pass.
27/// On any parse error or empty input, falls back to lowercase hex encoding.
28///
29/// # Format Specification
30///
31/// Each format specification has the form: `[*]<length><format>[separator][terminator]`
32///
33/// - `*` (optional): First data byte is repeat count for this spec
34/// - `length`: Decimal digits specifying bytes to consume per application
35/// - `format`: One of `d` (decimal), `x` (hex), `o` (octal), `a` (ASCII), `t` (UTF-8)
36/// - `separator` (optional): Character to emit between formatted segments
37/// - `terminator` (optional): Character after repeat group (only with `*`)
38///
39/// The last format specification repeats until all data is exhausted (implicit
40/// repetition rule). Trailing separators are suppressed.
41///
42/// # Examples
43///
44/// ```
45/// use async_snmp::format::display_hint;
46///
47/// // IPv4 address
48/// assert_eq!(display_hint::apply("1d.1d.1d.1d", &[192, 168, 1, 1]), "192.168.1.1");
49///
50/// // MAC address (implicit repetition)
51/// assert_eq!(display_hint::apply("1x:", &[0x00, 0x1a, 0x2b, 0x3c, 0x4d, 0x5e]), "00:1a:2b:3c:4d:5e");
52///
53/// // Star prefix with terminator
54/// assert_eq!(display_hint::apply("*1d./1d", &[3, 10, 20, 30, 40]), "10.20.30/40");
55///
56/// // Empty hint returns hex
57/// assert_eq!(display_hint::apply("", &[1, 2, 3]), "010203");
58///
59/// // Empty data returns empty string
60/// assert_eq!(display_hint::apply("1d", &[]), "");
61/// ```
62pub fn apply(hint: &str, data: &[u8]) -> String {
63    if hint.is_empty() || data.is_empty() {
64        return hex_encode(data);
65    }
66
67    let hint = hint.as_bytes();
68    let mut result = String::with_capacity(data.len() * 4);
69    let mut hint_pos = 0;
70    let mut data_pos = 0;
71    let mut last_spec_start = 0;
72
73    while data_pos < data.len() {
74        // If we've exhausted the hint, restart from the last spec (implicit repetition)
75        if hint_pos >= hint.len() {
76            hint_pos = last_spec_start;
77        }
78
79        let spec_start = hint_pos;
80
81        // (1) Optional '*' repeat indicator
82        let star_prefix = if hint_pos < hint.len() && hint[hint_pos] == b'*' {
83            hint_pos += 1;
84            true
85        } else {
86            false
87        };
88
89        // (2) Octet length - one or more decimal digits (required)
90        if hint_pos >= hint.len() || !is_digit(hint[hint_pos]) {
91            return hex_encode(data);
92        }
93
94        let mut take = 0usize;
95        while hint_pos < hint.len() && is_digit(hint[hint_pos]) {
96            take = take * 10 + (hint[hint_pos] - b'0') as usize;
97            hint_pos += 1;
98        }
99
100        if take == 0 {
101            return hex_encode(data);
102        }
103
104        // (3) Format character (required)
105        if hint_pos >= hint.len() {
106            return hex_encode(data);
107        }
108
109        let fmt_char = hint[hint_pos];
110        if !matches!(fmt_char, b'd' | b'x' | b'o' | b'a' | b't') {
111            return hex_encode(data);
112        }
113        hint_pos += 1;
114
115        // (4) Optional separator
116        let (sep, has_sep) =
117            if hint_pos < hint.len() && !is_digit(hint[hint_pos]) && hint[hint_pos] != b'*' {
118                let s = hint[hint_pos];
119                hint_pos += 1;
120                (s, true)
121            } else {
122                (0, false)
123            };
124
125        // (5) Optional terminator (only valid with star_prefix)
126        let (term, has_term) = if star_prefix
127            && hint_pos < hint.len()
128            && !is_digit(hint[hint_pos])
129            && hint[hint_pos] != b'*'
130        {
131            let t = hint[hint_pos];
132            hint_pos += 1;
133            (t, true)
134        } else {
135            (0, false)
136        };
137
138        // Remember this spec for implicit repetition
139        last_spec_start = spec_start;
140
141        // Apply the spec to data
142        let repeat_count = if star_prefix && data_pos < data.len() {
143            let count = data[data_pos] as usize;
144            data_pos += 1;
145            count
146        } else {
147            1
148        };
149
150        for r in 0..repeat_count {
151            if data_pos >= data.len() {
152                break;
153            }
154
155            let end = (data_pos + take).min(data.len());
156            let chunk = &data[data_pos..end];
157
158            // Format the chunk
159            match fmt_char {
160                b'd' => {
161                    // Big-endian unsigned integer
162                    let val = chunk.iter().fold(0u64, |acc, &b| (acc << 8) | u64::from(b));
163                    let _ = write!(result, "{}", val);
164                }
165                b'x' => {
166                    // Hex encoding - zero-padded per byte
167                    write_hex(&mut result, chunk);
168                }
169                b'o' => {
170                    // Big-endian octal
171                    let val = chunk.iter().fold(0u64, |acc, &b| (acc << 8) | u64::from(b));
172                    let _ = write!(result, "{:o}", val);
173                }
174                b'a' | b't' => {
175                    // ASCII/UTF-8 - write bytes directly
176                    for &b in chunk {
177                        result.push(b as char);
178                    }
179                }
180                _ => unreachable!(),
181            }
182            data_pos = end;
183
184            // Emit separator (suppressed if at end of data)
185            let more_data = data_pos < data.len();
186            if has_sep && more_data {
187                // Suppress separator before terminator
188                if has_term && r == repeat_count - 1 {
189                    // Don't emit separator, terminator will follow
190                } else {
191                    result.push(sep as char);
192                }
193            }
194        }
195
196        // Emit terminator after repeat group
197        if has_term && data_pos < data.len() {
198            result.push(term as char);
199        }
200    }
201
202    result
203}
204
205/// Encode bytes as lowercase hex string.
206fn hex_encode(data: &[u8]) -> String {
207    let mut out = String::with_capacity(data.len() * 2);
208    write_hex(&mut out, data);
209    out
210}
211
212/// Write hex-encoded bytes to a String.
213fn write_hex(out: &mut String, data: &[u8]) {
214    const HEX_TABLE: &[u8; 16] = b"0123456789abcdef";
215    for &b in data {
216        out.push(HEX_TABLE[(b >> 4) as usize] as char);
217        out.push(HEX_TABLE[(b & 0x0f) as usize] as char);
218    }
219}
220
221#[inline]
222fn is_digit(c: u8) -> bool {
223    c.is_ascii_digit()
224}
225
226/// Apply RFC 2579 DISPLAY-HINT formatting to an integer value.
227///
228/// INTEGER hints have the form: `<format>[-<decimal-places>]`
229///
230/// Format characters:
231/// - `d` or `d-N`: Decimal, optionally with N implied decimal places
232/// - `x`: Lowercase hexadecimal
233/// - `o`: Octal
234/// - `b`: Binary
235///
236/// Returns `None` for invalid or unsupported hint formats.
237///
238/// # Examples
239///
240/// ```
241/// use async_snmp::format::display_hint;
242///
243/// // Basic formats
244/// assert_eq!(display_hint::apply_integer("d", 1234), Some("1234".to_string()));
245/// assert_eq!(display_hint::apply_integer("x", 255), Some("ff".to_string()));
246/// assert_eq!(display_hint::apply_integer("o", 8), Some("10".to_string()));
247/// assert_eq!(display_hint::apply_integer("b", 5), Some("101".to_string()));
248///
249/// // Decimal places (DISPLAY-HINT "d-2" means 2 implied decimal places)
250/// assert_eq!(display_hint::apply_integer("d-2", 1234), Some("12.34".to_string()));
251/// assert_eq!(display_hint::apply_integer("d-2", 5), Some("0.05".to_string()));
252/// assert_eq!(display_hint::apply_integer("d-2", -500), Some("-5.00".to_string()));
253/// assert_eq!(display_hint::apply_integer("d-1", 255), Some("25.5".to_string()));
254/// ```
255pub fn apply_integer(hint: &str, value: i32) -> Option<String> {
256    match hint {
257        "x" => Some(format!("{:x}", value)),
258        "o" => Some(format!("{:o}", value)),
259        "b" => Some(format!("{:b}", value)),
260        "d" => Some(format!("{}", value)),
261        hint if hint.starts_with("d-") => {
262            let places: usize = hint[2..].parse().ok()?;
263            if places == 0 {
264                return Some(format!("{}", value));
265            }
266            Some(format_with_decimal_point(value, places))
267        }
268        _ => None,
269    }
270}
271
272/// Format an integer with an implied decimal point.
273///
274/// Uses pure string manipulation to avoid floating-point rounding issues.
275fn format_with_decimal_point(value: i32, places: usize) -> String {
276    let is_negative = value < 0;
277    let abs_value = value.unsigned_abs();
278    let abs_str = abs_value.to_string();
279
280    let result = if abs_str.len() <= places {
281        // Need to pad with leading zeros after decimal point
282        // e.g., 5 with places=2 -> "0.05"
283        let zeros_needed = places - abs_str.len();
284        format!("0.{}{}", "0".repeat(zeros_needed), abs_str)
285    } else {
286        // Insert decimal point
287        // e.g., 1234 with places=2 -> "12.34"
288        let split_point = abs_str.len() - places;
289        let (integer_part, decimal_part) = abs_str.split_at(split_point);
290        format!("{}.{}", integer_part, decimal_part)
291    };
292
293    if is_negative {
294        format!("-{}", result)
295    } else {
296        result
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    // ========================================================================
305    // Basic Format Tests
306    // ========================================================================
307
308    #[test]
309    fn empty_hint_returns_hex() {
310        assert_eq!(apply("", &[0x01, 0x02, 0x03]), "010203");
311    }
312
313    #[test]
314    fn empty_data_returns_empty() {
315        assert_eq!(apply("1d", &[]), "");
316    }
317
318    #[test]
319    fn ipv4_address() {
320        assert_eq!(apply("1d.1d.1d.1d", &[192, 168, 1, 1]), "192.168.1.1");
321    }
322
323    #[test]
324    fn ipv4_with_zone_id() {
325        assert_eq!(
326            apply("1d.1d.1d.1d%4d", &[192, 168, 1, 1, 0, 0, 0, 3]),
327            "192.168.1.1%3"
328        );
329    }
330
331    #[test]
332    fn mac_address() {
333        assert_eq!(
334            apply("1x:", &[0x00, 0x1a, 0x2b, 0x3c, 0x4d, 0x5e]),
335            "00:1a:2b:3c:4d:5e"
336        );
337    }
338
339    #[test]
340    fn ipv6_address() {
341        let data = [
342            0x20, 0x01, 0x0d, 0xb8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
343            0x00, 0x01,
344        ];
345        assert_eq!(
346            apply("2x:2x:2x:2x:2x:2x:2x:2x", &data),
347            "2001:0db8:0000:0000:0000:0000:0000:0001"
348        );
349    }
350
351    #[test]
352    fn ipv6_with_zone_id() {
353        let data = [
354            0xfe, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
355            0x00, 0x01, 0x00, 0x00, 0x00, 0x05,
356        ];
357        assert_eq!(
358            apply("2x:2x:2x:2x:2x:2x:2x:2x%4d", &data),
359            "fe80:0000:0000:0000:0000:0000:0000:0001%5"
360        );
361    }
362
363    #[test]
364    fn display_string() {
365        assert_eq!(apply("255a", b"Hello, World!"), "Hello, World!");
366    }
367
368    #[test]
369    fn simple_decimal() {
370        assert_eq!(apply("1d", &[42]), "42");
371    }
372
373    #[test]
374    fn multi_byte_decimal() {
375        assert_eq!(apply("4d", &[0x00, 0x01, 0x00, 0x00]), "65536");
376    }
377
378    #[test]
379    fn octal_format() {
380        assert_eq!(apply("1o", &[8]), "10");
381    }
382
383    #[test]
384    fn hex_with_dash_separator() {
385        assert_eq!(apply("1x-", &[0xaa, 0xbb, 0xcc]), "aa-bb-cc");
386    }
387
388    #[test]
389    fn star_prefix_repeat() {
390        assert_eq!(apply("*1x:", &[3, 0xaa, 0xbb, 0xcc]), "aa:bb:cc");
391    }
392
393    #[test]
394    fn star_prefix_with_terminator() {
395        assert_eq!(apply("*1d./1d", &[3, 10, 20, 30, 40]), "10.20.30/40");
396    }
397
398    #[test]
399    fn trailing_separator_suppressed() {
400        assert_eq!(apply("1d.", &[1, 2, 3]), "1.2.3");
401    }
402
403    #[test]
404    fn date_and_time() {
405        assert_eq!(
406            apply("2d-1d-1d,1d:1d:1d.1d", &[0x07, 0xE6, 8, 15, 8, 1, 15, 0]),
407            "2022-8-15,8:1:15.0"
408        );
409    }
410
411    #[test]
412    fn data_shorter_than_spec() {
413        assert_eq!(apply("1d.1d.1d.1d", &[10, 20]), "10.20");
414    }
415
416    #[test]
417    fn utf8_format() {
418        assert_eq!(apply("10t", b"hello"), "hello");
419    }
420
421    #[test]
422    fn uuid_format() {
423        let data = [
424            0x12, 0x34, 0x56, 0x78, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x00, 0x11, 0x22, 0x33,
425            0x44, 0x55,
426        ];
427        assert_eq!(
428            apply("4x-2x-2x-1x1x-6x", &data),
429            "12345678-abcd-ef01-2345-001122334455"
430        );
431    }
432
433    #[test]
434    fn ipv4_with_prefix() {
435        assert_eq!(apply("1d.1d.1d.1d/1d", &[10, 0, 0, 0, 24]), "10.0.0.0/24");
436    }
437
438    #[test]
439    fn two_digit_take_value() {
440        assert_eq!(
441            apply("10d", &[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01]),
442            "1"
443        );
444    }
445
446    #[test]
447    fn zero_padded_hex_output() {
448        assert_eq!(apply("1x", &[0x0f]), "0f");
449    }
450
451    #[test]
452    fn single_byte_trailing_separator_suppressed() {
453        assert_eq!(apply("1d.", &[42]), "42");
454    }
455
456    // ========================================================================
457    // Error Cases (should return hex fallback)
458    // ========================================================================
459
460    #[test]
461    fn invalid_format_character() {
462        assert_eq!(apply("1z", &[1, 2, 3]), "010203");
463    }
464
465    #[test]
466    fn missing_format_character() {
467        assert_eq!(apply("1", &[1, 2, 3]), "010203");
468    }
469
470    #[test]
471    fn missing_take_value() {
472        assert_eq!(apply("d", &[1, 2, 3]), "010203");
473    }
474
475    #[test]
476    fn zero_take_value() {
477        assert_eq!(apply("0d", &[1, 2, 3]), "010203");
478    }
479
480    // ========================================================================
481    // Implicit Repetition Tests
482    // ========================================================================
483
484    #[test]
485    fn single_spec_repeats_for_all_data() {
486        assert_eq!(apply("1d.", &[1, 2, 3, 4, 5]), "1.2.3.4.5");
487    }
488
489    #[test]
490    fn last_spec_repeats_after_fixed_prefix() {
491        assert_eq!(apply("1d-1d.", &[1, 2, 3, 4, 5, 6]), "1-2.3.4.5.6");
492    }
493
494    #[test]
495    fn hex_implicit_repetition() {
496        assert_eq!(apply("1x:", &[0xaa, 0xbb, 0xcc, 0xdd]), "aa:bb:cc:dd");
497    }
498
499    // ========================================================================
500    // INTEGER DISPLAY-HINT Tests
501    // ========================================================================
502
503    #[test]
504    fn integer_hint_decimal() {
505        assert_eq!(apply_integer("d", 1234), Some("1234".to_string()));
506        assert_eq!(apply_integer("d", -42), Some("-42".to_string()));
507        assert_eq!(apply_integer("d", 0), Some("0".to_string()));
508    }
509
510    #[test]
511    fn integer_hint_hex() {
512        assert_eq!(apply_integer("x", 255), Some("ff".to_string()));
513        assert_eq!(apply_integer("x", 0), Some("0".to_string()));
514        assert_eq!(apply_integer("x", 16), Some("10".to_string()));
515        // Negative values show as two's complement representation
516        assert_eq!(apply_integer("x", -1), Some("ffffffff".to_string()));
517    }
518
519    #[test]
520    fn integer_hint_octal() {
521        assert_eq!(apply_integer("o", 8), Some("10".to_string()));
522        assert_eq!(apply_integer("o", 64), Some("100".to_string()));
523        assert_eq!(apply_integer("o", 0), Some("0".to_string()));
524    }
525
526    #[test]
527    fn integer_hint_binary() {
528        assert_eq!(apply_integer("b", 5), Some("101".to_string()));
529        assert_eq!(apply_integer("b", 255), Some("11111111".to_string()));
530        assert_eq!(apply_integer("b", 0), Some("0".to_string()));
531    }
532
533    #[test]
534    fn integer_hint_decimal_places() {
535        // Standard cases
536        assert_eq!(apply_integer("d-2", 1234), Some("12.34".to_string()));
537        assert_eq!(apply_integer("d-1", 255), Some("25.5".to_string()));
538        assert_eq!(apply_integer("d-3", 12500), Some("12.500".to_string()));
539
540        // Small values need leading zeros after decimal
541        assert_eq!(apply_integer("d-2", 5), Some("0.05".to_string()));
542        assert_eq!(apply_integer("d-2", 50), Some("0.50".to_string()));
543        assert_eq!(apply_integer("d-3", 5), Some("0.005".to_string()));
544
545        // Zero
546        assert_eq!(apply_integer("d-2", 0), Some("0.00".to_string()));
547
548        // Negative values
549        assert_eq!(apply_integer("d-2", -500), Some("-5.00".to_string()));
550        assert_eq!(apply_integer("d-2", -5), Some("-0.05".to_string()));
551        assert_eq!(apply_integer("d-1", -42), Some("-4.2".to_string()));
552
553        // d-0 is just decimal
554        assert_eq!(apply_integer("d-0", 1234), Some("1234".to_string()));
555    }
556
557    #[test]
558    fn integer_hint_invalid() {
559        assert_eq!(apply_integer("", 42), None);
560        assert_eq!(apply_integer("z", 42), None);
561        assert_eq!(apply_integer("d-abc", 42), None);
562        assert_eq!(apply_integer("1d", 42), None); // OCTET STRING format, not INTEGER
563    }
564}