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