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::{CommonArgs, OutputArgs, OutputFormat, V3Args};
6use crate::cli::hints;
7use crate::format::hex;
8use crate::{Oid, Value, VarBind, Version};
9use serde::Serialize;
10use std::io::{self, Write};
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<'a> {
56    pub target: &'a str,
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/// Decoded representation of a Value, used by both verbose and normal output paths.
132struct DecodedValue {
133    type_name: String,
134    /// Human-readable display string (used by verbose output).
135    display: String,
136    /// JSON-serializable representation (used by structured output).
137    json_value: serde_json::Value,
138    /// Formatted display string for human output (timeticks, error messages, hex display).
139    formatted: Option<String>,
140    /// Compact hex encoding of raw bytes (used by JSON/structured output).
141    raw_hex: Option<String>,
142    /// Byte length (used by verbose output for byte types).
143    size: Option<usize>,
144}
145
146/// Decode a Value into its display components.
147fn decode_value(value: &Value, force_hex: bool) -> DecodedValue {
148    match value {
149        Value::Integer(v) => DecodedValue {
150            type_name: "INTEGER".into(),
151            display: v.to_string(),
152            json_value: (*v).into(),
153            formatted: None,
154            raw_hex: None,
155            size: None,
156        },
157
158        Value::OctetString(bytes) => {
159            let compact_hex = hex::encode(bytes);
160            let spaced_hex = format_hex_string(bytes);
161            let size = Some(bytes.len());
162
163            if force_hex || !hex::is_printable(bytes) {
164                DecodedValue {
165                    type_name: "Hex-STRING".into(),
166                    display: spaced_hex.clone(),
167                    json_value: serde_json::Value::String(compact_hex.clone()),
168                    formatted: Some(spaced_hex),
169                    raw_hex: Some(compact_hex),
170                    size,
171                }
172            } else {
173                let s = String::from_utf8_lossy(bytes).to_string();
174                DecodedValue {
175                    type_name: "STRING".into(),
176                    display: format!("\"{}\"", s),
177                    json_value: serde_json::Value::String(s),
178                    formatted: None,
179                    raw_hex: Some(compact_hex),
180                    size,
181                }
182            }
183        }
184
185        Value::Null => DecodedValue {
186            type_name: "NULL".into(),
187            display: "(null)".into(),
188            json_value: serde_json::Value::Null,
189            formatted: None,
190            raw_hex: None,
191            size: None,
192        },
193
194        Value::ObjectIdentifier(oid) => {
195            let s = format_oid(oid);
196            let hint = hints::lookup(oid);
197            let display = if let Some(h) = hint {
198                format!("{} ({})", s, h)
199            } else {
200                s.clone()
201            };
202            DecodedValue {
203                type_name: "OID".into(),
204                display,
205                json_value: serde_json::Value::String(s),
206                formatted: None,
207                raw_hex: None,
208                size: None,
209            }
210        }
211
212        Value::IpAddress(bytes) => {
213            let s = format!("{}.{}.{}.{}", bytes[0], bytes[1], bytes[2], bytes[3]);
214            DecodedValue {
215                type_name: "IpAddress".into(),
216                display: s.clone(),
217                json_value: serde_json::Value::String(s),
218                formatted: None,
219                raw_hex: None,
220                size: None,
221            }
222        }
223
224        Value::Counter32(v) => DecodedValue {
225            type_name: "Counter32".into(),
226            display: v.to_string(),
227            json_value: (*v).into(),
228            formatted: None,
229            raw_hex: None,
230            size: None,
231        },
232
233        Value::Gauge32(v) => DecodedValue {
234            type_name: "Gauge32".into(),
235            display: v.to_string(),
236            json_value: (*v).into(),
237            formatted: None,
238            raw_hex: None,
239            size: None,
240        },
241
242        Value::UInteger32(v) => DecodedValue {
243            type_name: "UInteger32".into(),
244            display: v.to_string(),
245            json_value: (*v).into(),
246            formatted: None,
247            raw_hex: None,
248            size: None,
249        },
250
251        Value::TimeTicks(v) => {
252            let human = format_timeticks(*v);
253            DecodedValue {
254                type_name: "TimeTicks".into(),
255                display: format!("({}) {}", v, human),
256                json_value: (*v).into(),
257                formatted: Some(format!("({}) {}", v, human)),
258                raw_hex: None,
259                size: None,
260            }
261        }
262
263        Value::Opaque(bytes) => {
264            let compact_hex = hex::encode(bytes);
265            let spaced_hex = format_hex_string(bytes);
266            DecodedValue {
267                type_name: "Opaque".into(),
268                display: spaced_hex.clone(),
269                json_value: serde_json::Value::String(compact_hex.clone()),
270                formatted: Some(spaced_hex),
271                raw_hex: Some(compact_hex),
272                size: Some(bytes.len()),
273            }
274        }
275
276        // NSAP addresses are binary; always render hex (matches net-snmp).
277        Value::Nsap(bytes) => {
278            let compact_hex = hex::encode(bytes);
279            let spaced_hex = format_hex_string(bytes);
280
281            DecodedValue {
282                type_name: "Nsap".into(),
283                display: spaced_hex.clone(),
284                json_value: serde_json::Value::String(compact_hex.clone()),
285                formatted: Some(spaced_hex),
286                raw_hex: Some(compact_hex),
287                size: Some(bytes.len()),
288            }
289        }
290
291        Value::Counter64(v) => DecodedValue {
292            type_name: "Counter64".into(),
293            display: v.to_string(),
294            json_value: (*v).into(),
295            formatted: None,
296            raw_hex: None,
297            size: None,
298        },
299
300        Value::NoSuchObject => DecodedValue {
301            type_name: "NoSuchObject".into(),
302            display: "No Such Object available".into(),
303            json_value: serde_json::Value::Null,
304            formatted: Some("No Such Object available".into()),
305            raw_hex: None,
306            size: None,
307        },
308
309        Value::NoSuchInstance => DecodedValue {
310            type_name: "NoSuchInstance".into(),
311            display: "No Such Instance currently exists".into(),
312            json_value: serde_json::Value::Null,
313            formatted: Some("No Such Instance currently exists".into()),
314            raw_hex: None,
315            size: None,
316        },
317
318        Value::EndOfMibView => DecodedValue {
319            type_name: "EndOfMibView".into(),
320            display: "No more variables left in this MIB View".into(),
321            json_value: serde_json::Value::Null,
322            formatted: Some("No more variables left in this MIB View".into()),
323            raw_hex: None,
324            size: None,
325        },
326
327        Value::Unknown { tag, data } => {
328            let compact_hex = hex::encode(data);
329            let spaced_hex = format_hex_string(data);
330            DecodedValue {
331                type_name: format!("Unknown(0x{:02X})", tag),
332                display: spaced_hex.clone(),
333                json_value: serde_json::Value::String(compact_hex.clone()),
334                formatted: Some(spaced_hex),
335                raw_hex: Some(compact_hex),
336                size: Some(data.len()),
337            }
338        }
339    }
340}
341
342/// Write detailed varbind information for verbose output.
343fn write_verbose_varbind<W: Write>(w: &mut W, vb: &VarBind, show_hints: bool) {
344    // OID with optional hint
345    let hint = if show_hints {
346        hints::lookup(&vb.oid)
347    } else {
348        None
349    };
350    if let Some(h) = hint {
351        let _ = writeln!(w, "  {} ({})", format_oid(&vb.oid), h);
352    } else {
353        let _ = writeln!(w, "  {}", format_oid(&vb.oid));
354    }
355
356    let decoded = decode_value(&vb.value, false);
357
358    let _ = writeln!(w, "    Type:    {}", decoded.type_name);
359    let _ = writeln!(w, "    Value:   {}", decoded.display);
360
361    if let Some(ref raw) = decoded.raw_hex {
362        let _ = writeln!(w, "    Raw:     {}", raw);
363    }
364
365    if let Some(s) = decoded.size {
366        let _ = writeln!(w, "    Size:    {} bytes", s);
367    }
368}
369
370/// Result of a GET/WALK operation, ready for output.
371#[derive(Debug, Serialize)]
372pub struct OperationResult {
373    pub target: String,
374    pub version: String,
375    pub results: Vec<VarBindResult>,
376    #[serde(skip_serializing_if = "Option::is_none")]
377    pub timing_ms: Option<f64>,
378    #[serde(skip_serializing_if = "Option::is_none")]
379    pub retries: Option<u32>,
380}
381
382/// A single varbind result.
383#[derive(Debug, Serialize)]
384pub struct VarBindResult {
385    pub oid: String,
386    #[serde(skip_serializing_if = "Option::is_none")]
387    pub hint: Option<String>,
388    #[serde(rename = "type")]
389    pub value_type: String,
390    pub value: serde_json::Value,
391    #[serde(skip_serializing_if = "Option::is_none")]
392    pub formatted: Option<String>,
393    #[serde(skip_serializing_if = "Option::is_none")]
394    pub raw_hex: Option<String>,
395}
396
397/// Trait for formatting OIDs and values using external metadata.
398///
399/// Implementors provide symbolic OID formatting and type-aware value rendering.
400/// Used by [`OutputContext`] to produce richer output when available.
401pub trait VarBindFormatter {
402    /// Format a numeric OID symbolically (e.g., "IF-MIB::ifDescr.1").
403    fn format_oid(&self, oid: &Oid) -> String;
404    /// Format a value using type metadata for the given OID.
405    fn format_value(&self, oid: &Oid, value: &Value) -> String;
406}
407
408/// Output context for formatting.
409pub struct OutputContext<'a> {
410    pub format: OutputFormat,
411    pub show_hints: bool,
412    pub force_hex: bool,
413    pub show_timing: bool,
414    /// Optional formatter for symbolic OID names and type-aware values.
415    pub formatter: Option<&'a dyn VarBindFormatter>,
416}
417
418impl<'a> OutputContext<'a> {
419    /// Create a new output context with default settings.
420    pub fn new(format: OutputFormat) -> Self {
421        Self {
422            format,
423            show_hints: true,
424            force_hex: false,
425            show_timing: false,
426            formatter: None,
427        }
428    }
429
430    /// Create an output context from CLI output arguments.
431    pub fn from_args(output: &OutputArgs) -> Self {
432        Self {
433            format: output.format,
434            show_hints: !output.no_hints,
435            force_hex: output.hex,
436            show_timing: output.timing,
437            formatter: None,
438        }
439    }
440
441    /// Write operation results to stdout.
442    pub fn write_results(
443        &self,
444        target: &str,
445        version: Version,
446        varbinds: &[VarBind],
447        elapsed: Option<Duration>,
448        retries: Option<u32>,
449    ) -> io::Result<()> {
450        let result = self.build_result(target, version, varbinds, elapsed, retries);
451        let mut stdout = io::stdout().lock();
452
453        match self.format {
454            OutputFormat::Human => self.write_human(&mut stdout, &result),
455            OutputFormat::Json => self.write_json(&mut stdout, &result),
456            OutputFormat::Raw => self.write_raw(&mut stdout, &result),
457        }
458    }
459
460    fn build_result(
461        &self,
462        target: &str,
463        version: Version,
464        varbinds: &[VarBind],
465        elapsed: Option<Duration>,
466        retries: Option<u32>,
467    ) -> OperationResult {
468        let results = varbinds.iter().map(|vb| self.format_varbind(vb)).collect();
469
470        OperationResult {
471            target: target.to_string(),
472            version: format!("{:?}", version),
473            results,
474            timing_ms: elapsed.map(|d| d.as_secs_f64() * 1000.0),
475            retries,
476        }
477    }
478
479    fn format_varbind(&self, vb: &VarBind) -> VarBindResult {
480        if let Some(fmt) = self.formatter {
481            return self.format_varbind_with_formatter(fmt, vb);
482        }
483
484        let oid_str = format_oid(&vb.oid);
485        let hint = if self.show_hints {
486            hints::lookup(&vb.oid).map(String::from)
487        } else {
488            None
489        };
490
491        let decoded = decode_value(&vb.value, self.force_hex);
492
493        VarBindResult {
494            oid: oid_str,
495            hint,
496            value_type: decoded.type_name,
497            value: decoded.json_value,
498            formatted: decoded.formatted,
499            raw_hex: decoded.raw_hex,
500        }
501    }
502
503    fn format_varbind_with_formatter(
504        &self,
505        fmt: &dyn VarBindFormatter,
506        vb: &VarBind,
507    ) -> VarBindResult {
508        let oid_str = fmt.format_oid(&vb.oid);
509        let formatted_value = fmt.format_value(&vb.oid, &vb.value);
510        let decoded = decode_value(&vb.value, self.force_hex);
511
512        VarBindResult {
513            oid: oid_str,
514            hint: None, // Formatter provides the OID name directly
515            value_type: decoded.type_name,
516            value: decoded.json_value,
517            formatted: Some(formatted_value),
518            raw_hex: decoded.raw_hex,
519        }
520    }
521
522    fn write_human<W: Write>(&self, w: &mut W, result: &OperationResult) -> io::Result<()> {
523        for vb in &result.results {
524            // OID with optional hint
525            if let Some(ref hint) = vb.hint {
526                write!(w, "{} ({})", vb.oid, hint)?;
527            } else {
528                write!(w, "{}", vb.oid)?;
529            }
530
531            // Type and value
532            write!(w, " = {}: ", vb.value_type)?;
533
534            // Value - prefer formatted for display
535            if let Some(ref formatted) = vb.formatted {
536                writeln!(w, "{}", formatted)?;
537            } else {
538                match &vb.value {
539                    serde_json::Value::String(s) => writeln!(w, "\"{}\"", s)?,
540                    serde_json::Value::Null => writeln!(w)?,
541                    other => writeln!(w, "{}", other)?,
542                }
543            }
544        }
545
546        if self.show_timing
547            && let Some(ms) = result.timing_ms
548        {
549            if let Some(retries) = result.retries {
550                writeln!(w, "\nTiming: {:.1}ms ({} retries)", ms, retries)?;
551            } else {
552                writeln!(w, "\nTiming: {:.1}ms", ms)?;
553            }
554        }
555
556        Ok(())
557    }
558
559    fn write_json<W: Write>(&self, w: &mut W, result: &OperationResult) -> io::Result<()> {
560        let json = serde_json::to_string_pretty(result).map_err(io::Error::other)?;
561        writeln!(w, "{}", json)
562    }
563
564    fn write_raw<W: Write>(&self, w: &mut W, result: &OperationResult) -> io::Result<()> {
565        for vb in &result.results {
566            let value_str = match &vb.value {
567                serde_json::Value::String(s) => s.clone(),
568                serde_json::Value::Null => String::new(),
569                other => other.to_string(),
570            };
571            writeln!(w, "{}\t{}", vb.oid, value_str)?;
572        }
573        Ok(())
574    }
575}
576
577/// Format an OID as dotted string.
578fn format_oid(oid: &Oid) -> String {
579    oid.arcs()
580        .iter()
581        .map(|a| a.to_string())
582        .collect::<Vec<_>>()
583        .join(".")
584}
585
586/// Format bytes as spaced hex for display.
587fn format_hex_string(bytes: &[u8]) -> String {
588    crate::format::format_hex_display(bytes)
589}
590
591/// Format TimeTicks as human-readable duration.
592fn format_timeticks(centiseconds: u32) -> String {
593    crate::format::format_timeticks(centiseconds)
594}
595
596/// Build a SecurityInfo from CLI arguments.
597pub fn build_security_info(v3: &V3Args, common: &CommonArgs) -> SecurityInfo {
598    if v3.is_v3() {
599        SecurityInfo::V3 {
600            username: v3.username.clone().unwrap_or_default(),
601            auth_protocol: v3.auth_protocol.map(|p| format!("{}", p)),
602            priv_protocol: v3.priv_protocol.map(|p| format!("{}", p)),
603        }
604    } else {
605        SecurityInfo::Community(common.community.clone())
606    }
607}
608
609/// Write an error message to stderr.
610pub fn write_error(err: &crate::Error) {
611    eprintln!("Error: {}", err);
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617
618    #[test]
619    fn test_format_timeticks() {
620        // 1 day, 10 hours, 17 minutes, 36.78 seconds = 123456.78 seconds = 12345678 centiseconds
621        assert_eq!(format_timeticks(12345678), "1d 10:17:36.78");
622
623        // Less than a day
624        assert_eq!(format_timeticks(360000), "01:00:00.00");
625
626        // Zero
627        assert_eq!(format_timeticks(0), "00:00:00.00");
628    }
629
630    #[test]
631    fn test_is_printable() {
632        assert!(hex::is_printable(b"Hello World"));
633        assert!(hex::is_printable(b"Line 1\nLine 2"));
634        assert!(hex::is_printable(b""));
635        assert!(!hex::is_printable(&[0x00, 0x01, 0x02]));
636        assert!(!hex::is_printable(&[0x80, 0x81]));
637    }
638
639    #[test]
640    fn test_hex_encode() {
641        assert_eq!(hex::encode(&[0x00, 0x1A, 0x2B]), "001a2b");
642    }
643
644    #[test]
645    fn test_format_hex_string() {
646        assert_eq!(format_hex_string(&[0x00, 0x1A, 0x2B]), "00 1A 2B");
647    }
648}