Skip to main content

async_snmp/cli/
output.rs

1//! Output formatting for CLI tools.
2//!
3//! Supports human-readable, JSON, and raw output formats.
4
5use crate::cli::args::OutputFormat;
6use crate::cli::hints;
7use crate::{Oid, Value, VarBind, Version};
8use serde::Serialize;
9use std::io::{self, Write};
10use std::net::SocketAddr;
11use std::time::Duration;
12
13/// Operation type for verbose output.
14#[derive(Debug, Clone, Copy)]
15pub enum OperationType {
16    Get,
17    GetNext,
18    GetBulk {
19        non_repeaters: i32,
20        max_repetitions: i32,
21    },
22    Set,
23    Walk,
24    BulkWalk {
25        max_repetitions: i32,
26    },
27}
28
29impl std::fmt::Display for OperationType {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        match self {
32            Self::Get => write!(f, "GET"),
33            Self::GetNext => write!(f, "GETNEXT"),
34            Self::GetBulk { .. } => write!(f, "GETBULK"),
35            Self::Set => write!(f, "SET"),
36            Self::Walk => write!(f, "WALK (GETNEXT)"),
37            Self::BulkWalk { .. } => write!(f, "WALK (GETBULK)"),
38        }
39    }
40}
41
42/// Security info for verbose output.
43#[derive(Debug, Clone)]
44pub enum SecurityInfo {
45    Community(String),
46    V3 {
47        username: String,
48        auth_protocol: Option<String>,
49        priv_protocol: Option<String>,
50    },
51}
52
53/// Request metadata for verbose output.
54#[derive(Debug)]
55pub struct RequestInfo {
56    pub target: SocketAddr,
57    pub version: Version,
58    pub security: SecurityInfo,
59    pub operation: OperationType,
60    pub oids: Vec<Oid>,
61}
62
63/// Write verbose request header to stderr.
64pub fn write_verbose_request(info: &RequestInfo) {
65    let mut stderr = std::io::stderr().lock();
66    let _ = writeln!(stderr, "--- Request ---");
67    let _ = writeln!(stderr, "Target:    {}", info.target);
68    let _ = writeln!(stderr, "Version:   {:?}", info.version);
69
70    match &info.security {
71        SecurityInfo::Community(c) => {
72            let _ = writeln!(stderr, "Community: {}", c);
73        }
74        SecurityInfo::V3 {
75            username,
76            auth_protocol,
77            priv_protocol,
78        } => {
79            let _ = writeln!(stderr, "Username:  {}", username);
80            if let Some(auth) = auth_protocol {
81                let _ = writeln!(stderr, "Auth:      {}", auth);
82            }
83            if let Some(priv_p) = priv_protocol {
84                let _ = writeln!(stderr, "Privacy:   {}", priv_p);
85            }
86        }
87    }
88
89    let _ = writeln!(stderr, "Operation: {}", info.operation);
90
91    if let OperationType::GetBulk {
92        non_repeaters,
93        max_repetitions,
94    } = info.operation
95    {
96        let _ = writeln!(stderr, "  Non-repeaters:    {}", non_repeaters);
97        let _ = writeln!(stderr, "  Max-repetitions:  {}", max_repetitions);
98    } else if let OperationType::BulkWalk { max_repetitions } = info.operation {
99        let _ = writeln!(stderr, "  Max-repetitions:  {}", max_repetitions);
100    }
101
102    let _ = writeln!(stderr, "OIDs:      {} total", info.oids.len());
103    for oid in &info.oids {
104        let hint = hints::lookup(oid);
105        if let Some(h) = hint {
106            let _ = writeln!(stderr, "  {} ({})", oid, h);
107        } else {
108            let _ = writeln!(stderr, "  {}", oid);
109        }
110    }
111    let _ = writeln!(stderr);
112}
113
114/// Write verbose response summary to stderr.
115pub fn write_verbose_response(varbinds: &[VarBind], elapsed: Duration, show_hints: bool) {
116    let mut stderr = std::io::stderr().lock();
117    let _ = writeln!(stderr, "--- Response ---");
118    let _ = writeln!(stderr, "Results:   {} varbind(s)", varbinds.len());
119    let _ = writeln!(stderr, "Time:      {:.2}ms", elapsed.as_secs_f64() * 1000.0);
120    let _ = writeln!(stderr);
121
122    for vb in varbinds {
123        write_verbose_varbind(&mut stderr, vb, show_hints);
124    }
125
126    if !varbinds.is_empty() {
127        let _ = writeln!(stderr);
128    }
129}
130
131/// Write detailed varbind information for verbose output.
132fn write_verbose_varbind<W: Write>(w: &mut W, vb: &VarBind, show_hints: bool) {
133    // OID with optional hint
134    let hint = if show_hints {
135        hints::lookup(&vb.oid)
136    } else {
137        None
138    };
139    if let Some(h) = hint {
140        let _ = writeln!(w, "  {} ({})", format_oid(&vb.oid), h);
141    } else {
142        let _ = writeln!(w, "  {}", format_oid(&vb.oid));
143    }
144
145    // Type and value details
146    let (type_name, decoded, raw_hex, size) = format_verbose_value(&vb.value);
147
148    let _ = writeln!(w, "    Type:    {}", type_name);
149    let _ = writeln!(w, "    Value:   {}", decoded);
150
151    if let Some(hex) = raw_hex {
152        let _ = writeln!(w, "    Raw:     {}", hex);
153    }
154
155    if let Some(s) = size {
156        let _ = writeln!(w, "    Size:    {} bytes", s);
157    }
158}
159
160/// Format a value for verbose output, returning (type_name, decoded_value, raw_hex, size).
161fn format_verbose_value(value: &Value) -> (String, String, Option<String>, Option<usize>) {
162    match value {
163        Value::Integer(v) => ("INTEGER".into(), format!("{}", v), None, None),
164
165        Value::OctetString(bytes) => {
166            let raw_hex = format_hex_string(bytes);
167            let size = Some(bytes.len());
168
169            if is_printable(bytes) {
170                let decoded = String::from_utf8_lossy(bytes).to_string();
171                (
172                    "STRING".into(),
173                    format!("\"{}\"", decoded),
174                    Some(raw_hex),
175                    size,
176                )
177            } else {
178                ("Hex-STRING".into(), raw_hex.clone(), Some(raw_hex), size)
179            }
180        }
181
182        Value::Null => ("NULL".into(), "(null)".into(), None, None),
183
184        Value::ObjectIdentifier(oid) => {
185            let s = format_oid(oid);
186            let hint = hints::lookup(oid);
187            let decoded = if let Some(h) = hint {
188                format!("{} ({})", s, h)
189            } else {
190                s
191            };
192            ("OID".into(), decoded, None, None)
193        }
194
195        Value::IpAddress(bytes) => {
196            let s = format!("{}.{}.{}.{}", bytes[0], bytes[1], bytes[2], bytes[3]);
197            ("IpAddress".into(), s, None, None)
198        }
199
200        Value::Counter32(v) => ("Counter32".into(), format!("{}", v), None, None),
201
202        Value::Gauge32(v) => ("Gauge32".into(), format!("{}", v), None, None),
203
204        Value::TimeTicks(v) => {
205            let formatted = format_timeticks(*v);
206            (
207                "TimeTicks".into(),
208                format!("{} ({})", v, formatted),
209                None,
210                None,
211            )
212        }
213
214        Value::Opaque(bytes) => {
215            let raw_hex = format_hex_string(bytes);
216            (
217                "Opaque".into(),
218                raw_hex.clone(),
219                Some(raw_hex),
220                Some(bytes.len()),
221            )
222        }
223
224        Value::Counter64(v) => ("Counter64".into(), format!("{}", v), None, None),
225
226        Value::NoSuchObject => (
227            "NoSuchObject".into(),
228            "No Such Object available".into(),
229            None,
230            None,
231        ),
232
233        Value::NoSuchInstance => (
234            "NoSuchInstance".into(),
235            "No Such Instance currently exists".into(),
236            None,
237            None,
238        ),
239
240        Value::EndOfMibView => (
241            "EndOfMibView".into(),
242            "No more variables left in this MIB View".into(),
243            None,
244            None,
245        ),
246
247        Value::Unknown { tag, data } => {
248            let raw_hex = format_hex_string(data);
249            (
250                format!("Unknown(0x{:02X})", tag),
251                raw_hex.clone(),
252                Some(raw_hex),
253                Some(data.len()),
254            )
255        }
256    }
257}
258
259/// Result of a GET/WALK operation, ready for output.
260#[derive(Debug, Serialize)]
261pub struct OperationResult {
262    pub target: String,
263    pub version: String,
264    pub results: Vec<VarBindResult>,
265    #[serde(skip_serializing_if = "Option::is_none")]
266    pub timing_ms: Option<f64>,
267    #[serde(skip_serializing_if = "Option::is_none")]
268    pub retries: Option<u32>,
269}
270
271/// A single varbind result.
272#[derive(Debug, Serialize)]
273pub struct VarBindResult {
274    pub oid: String,
275    #[serde(skip_serializing_if = "Option::is_none")]
276    pub hint: Option<String>,
277    #[serde(rename = "type")]
278    pub value_type: String,
279    pub value: serde_json::Value,
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub formatted: Option<String>,
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub raw_hex: Option<String>,
284}
285
286/// Trait for formatting OIDs and values using external metadata.
287///
288/// Implementors provide symbolic OID formatting and type-aware value rendering.
289/// Used by [`OutputContext`] to produce richer output when available.
290pub trait VarBindFormatter {
291    /// Format a numeric OID symbolically (e.g., "IF-MIB::ifDescr.1").
292    fn format_oid(&self, oid: &Oid) -> String;
293    /// Format a value using type metadata for the given OID.
294    fn format_value(&self, oid: &Oid, value: &Value) -> String;
295}
296
297/// Output context for formatting.
298pub struct OutputContext<'a> {
299    pub format: OutputFormat,
300    pub show_hints: bool,
301    pub force_hex: bool,
302    pub show_timing: bool,
303    /// Optional formatter for symbolic OID names and type-aware values.
304    pub formatter: Option<&'a dyn VarBindFormatter>,
305}
306
307impl<'a> OutputContext<'a> {
308    /// Create a new output context with default settings.
309    pub fn new(format: OutputFormat) -> Self {
310        Self {
311            format,
312            show_hints: true,
313            force_hex: false,
314            show_timing: false,
315            formatter: None,
316        }
317    }
318
319    /// Write operation results to stdout.
320    pub fn write_results(
321        &self,
322        target: SocketAddr,
323        version: Version,
324        varbinds: &[VarBind],
325        elapsed: Option<Duration>,
326        retries: Option<u32>,
327    ) -> io::Result<()> {
328        let result = self.build_result(target, version, varbinds, elapsed, retries);
329        let mut stdout = io::stdout().lock();
330
331        match self.format {
332            OutputFormat::Human => self.write_human(&mut stdout, &result),
333            OutputFormat::Json => self.write_json(&mut stdout, &result),
334            OutputFormat::Raw => self.write_raw(&mut stdout, &result),
335        }
336    }
337
338    fn build_result(
339        &self,
340        target: SocketAddr,
341        version: Version,
342        varbinds: &[VarBind],
343        elapsed: Option<Duration>,
344        retries: Option<u32>,
345    ) -> OperationResult {
346        let results = varbinds.iter().map(|vb| self.format_varbind(vb)).collect();
347
348        OperationResult {
349            target: target.to_string(),
350            version: format!("{:?}", version),
351            results,
352            timing_ms: elapsed.map(|d| d.as_secs_f64() * 1000.0),
353            retries,
354        }
355    }
356
357    fn format_varbind(&self, vb: &VarBind) -> VarBindResult {
358        if let Some(fmt) = self.formatter {
359            return self.format_varbind_with_formatter(fmt, vb);
360        }
361
362        let oid_str = format_oid(&vb.oid);
363        let hint = if self.show_hints {
364            hints::lookup(&vb.oid).map(String::from)
365        } else {
366            None
367        };
368
369        let (value_type, value, formatted, raw_hex) = format_value(&vb.value, self.force_hex);
370
371        VarBindResult {
372            oid: oid_str,
373            hint,
374            value_type,
375            value,
376            formatted,
377            raw_hex,
378        }
379    }
380
381    fn format_varbind_with_formatter(
382        &self,
383        fmt: &dyn VarBindFormatter,
384        vb: &VarBind,
385    ) -> VarBindResult {
386        let oid_str = fmt.format_oid(&vb.oid);
387        let formatted_value = fmt.format_value(&vb.oid, &vb.value);
388        let (value_type, value, _, raw_hex) = format_value(&vb.value, self.force_hex);
389
390        VarBindResult {
391            oid: oid_str,
392            hint: None, // Formatter provides the OID name directly
393            value_type,
394            value,
395            formatted: Some(formatted_value),
396            raw_hex,
397        }
398    }
399
400    fn write_human<W: Write>(&self, w: &mut W, result: &OperationResult) -> io::Result<()> {
401        for vb in &result.results {
402            // OID with optional hint
403            if let Some(ref hint) = vb.hint {
404                write!(w, "{} ({})", vb.oid, hint)?;
405            } else {
406                write!(w, "{}", vb.oid)?;
407            }
408
409            // Type and value
410            write!(w, " = {}: ", vb.value_type)?;
411
412            // Value - prefer formatted for display
413            if let Some(ref formatted) = vb.formatted {
414                writeln!(w, "{}", formatted)?;
415            } else {
416                match &vb.value {
417                    serde_json::Value::String(s) => writeln!(w, "\"{}\"", s)?,
418                    serde_json::Value::Null => writeln!(w)?,
419                    other => writeln!(w, "{}", other)?,
420                }
421            }
422        }
423
424        if self.show_timing
425            && let Some(ms) = result.timing_ms
426        {
427            if let Some(retries) = result.retries {
428                writeln!(w, "\nTiming: {:.1}ms ({} retries)", ms, retries)?;
429            } else {
430                writeln!(w, "\nTiming: {:.1}ms", ms)?;
431            }
432        }
433
434        Ok(())
435    }
436
437    fn write_json<W: Write>(&self, w: &mut W, result: &OperationResult) -> io::Result<()> {
438        let json = serde_json::to_string_pretty(result).map_err(io::Error::other)?;
439        writeln!(w, "{}", json)
440    }
441
442    fn write_raw<W: Write>(&self, w: &mut W, result: &OperationResult) -> io::Result<()> {
443        for vb in &result.results {
444            let value_str = match &vb.value {
445                serde_json::Value::String(s) => s.clone(),
446                serde_json::Value::Null => String::new(),
447                other => other.to_string(),
448            };
449            writeln!(w, "{}\t{}", vb.oid, value_str)?;
450        }
451        Ok(())
452    }
453}
454
455/// Format an OID as dotted string.
456fn format_oid(oid: &Oid) -> String {
457    oid.arcs()
458        .iter()
459        .map(|a| a.to_string())
460        .collect::<Vec<_>>()
461        .join(".")
462}
463
464/// Format a value, returning (type_name, json_value, formatted_string, raw_hex).
465fn format_value(
466    value: &Value,
467    force_hex: bool,
468) -> (String, serde_json::Value, Option<String>, Option<String>) {
469    match value {
470        Value::Integer(v) => ("INTEGER".into(), (*v).into(), None, None),
471
472        Value::OctetString(bytes) => {
473            let raw_hex = Some(hex_string(bytes));
474
475            if force_hex || !is_printable(bytes) {
476                let formatted = format_hex_string(bytes);
477                (
478                    "Hex-STRING".into(),
479                    serde_json::Value::String(raw_hex.clone().unwrap()),
480                    Some(formatted),
481                    raw_hex,
482                )
483            } else {
484                let s = String::from_utf8_lossy(bytes);
485                (
486                    "STRING".into(),
487                    serde_json::Value::String(s.to_string()),
488                    None,
489                    raw_hex,
490                )
491            }
492        }
493
494        Value::Null => ("NULL".into(), serde_json::Value::Null, None, None),
495
496        Value::ObjectIdentifier(oid) => {
497            let s = format_oid(oid);
498            ("OID".into(), serde_json::Value::String(s), None, None)
499        }
500
501        Value::IpAddress(bytes) => {
502            let s = format!("{}.{}.{}.{}", bytes[0], bytes[1], bytes[2], bytes[3]);
503            ("IpAddress".into(), serde_json::Value::String(s), None, None)
504        }
505
506        Value::Counter32(v) => ("Counter32".into(), (*v).into(), None, None),
507
508        Value::Gauge32(v) => ("Gauge32".into(), (*v).into(), None, None),
509
510        Value::TimeTicks(v) => {
511            let formatted = format_timeticks(*v);
512            (
513                "TimeTicks".into(),
514                (*v).into(),
515                Some(format!("({}) {}", v, formatted)),
516                None,
517            )
518        }
519
520        Value::Opaque(bytes) => {
521            let hex = hex_string(bytes);
522            (
523                "Opaque".into(),
524                serde_json::Value::String(hex.clone()),
525                Some(format_hex_string(bytes)),
526                Some(hex),
527            )
528        }
529
530        Value::Counter64(v) => ("Counter64".into(), (*v).into(), None, None),
531
532        Value::NoSuchObject => (
533            "NoSuchObject".into(),
534            serde_json::Value::Null,
535            Some("No Such Object available".into()),
536            None,
537        ),
538
539        Value::NoSuchInstance => (
540            "NoSuchInstance".into(),
541            serde_json::Value::Null,
542            Some("No Such Instance currently exists".into()),
543            None,
544        ),
545
546        Value::EndOfMibView => (
547            "EndOfMibView".into(),
548            serde_json::Value::Null,
549            Some("No more variables left in this MIB View".into()),
550            None,
551        ),
552
553        Value::Unknown { tag, data } => {
554            let hex = hex_string(data);
555            (
556                format!("Unknown(0x{:02X})", tag),
557                serde_json::Value::String(hex.clone()),
558                Some(format_hex_string(data)),
559                Some(hex),
560            )
561        }
562    }
563}
564
565/// Check if bytes are printable ASCII/UTF-8.
566fn is_printable(bytes: &[u8]) -> bool {
567    if bytes.is_empty() {
568        return true;
569    }
570
571    // Try UTF-8 first
572    if let Ok(s) = std::str::from_utf8(bytes) {
573        // Check that all characters are printable
574        s.chars()
575            .all(|c| c.is_ascii_graphic() || c.is_ascii_whitespace())
576    } else {
577        false
578    }
579}
580
581/// Format bytes as hex string (lowercase, no separator).
582fn hex_string(bytes: &[u8]) -> String {
583    bytes.iter().map(|b| format!("{:02x}", b)).collect()
584}
585
586/// Format bytes as spaced hex for display.
587fn format_hex_string(bytes: &[u8]) -> String {
588    crate::fmt::format_hex_display(bytes)
589}
590
591/// Format TimeTicks as human-readable duration.
592fn format_timeticks(centiseconds: u32) -> String {
593    crate::fmt::format_timeticks(centiseconds)
594}
595
596/// Write an error message to stderr.
597pub fn write_error(err: &crate::Error) {
598    eprintln!("Error: {}", err);
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604
605    #[test]
606    fn test_format_timeticks() {
607        // 1 day, 10 hours, 17 minutes, 36.78 seconds = 123456.78 seconds = 12345678 centiseconds
608        assert_eq!(format_timeticks(12345678), "1d 10:17:36.78");
609
610        // Less than a day
611        assert_eq!(format_timeticks(360000), "01:00:00.00");
612
613        // Zero
614        assert_eq!(format_timeticks(0), "00:00:00.00");
615    }
616
617    #[test]
618    fn test_is_printable() {
619        assert!(is_printable(b"Hello World"));
620        assert!(is_printable(b"Line 1\nLine 2"));
621        assert!(is_printable(b""));
622        assert!(!is_printable(&[0x00, 0x01, 0x02]));
623        assert!(!is_printable(&[0x80, 0x81]));
624    }
625
626    #[test]
627    fn test_hex_string() {
628        assert_eq!(hex_string(&[0x00, 0x1A, 0x2B]), "001a2b");
629    }
630
631    #[test]
632    fn test_format_hex_string() {
633        assert_eq!(format_hex_string(&[0x00, 0x1A, 0x2B]), "00 1A 2B");
634    }
635}