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::TimeTicks(v) => {
243            let human = format_timeticks(*v);
244            DecodedValue {
245                type_name: "TimeTicks".into(),
246                display: format!("({}) {}", v, human),
247                json_value: (*v).into(),
248                formatted: Some(format!("({}) {}", v, human)),
249                raw_hex: None,
250                size: None,
251            }
252        }
253
254        Value::Opaque(bytes) => {
255            let compact_hex = hex::encode(bytes);
256            let spaced_hex = format_hex_string(bytes);
257            DecodedValue {
258                type_name: "Opaque".into(),
259                display: spaced_hex.clone(),
260                json_value: serde_json::Value::String(compact_hex.clone()),
261                formatted: Some(spaced_hex),
262                raw_hex: Some(compact_hex),
263                size: Some(bytes.len()),
264            }
265        }
266
267        Value::Counter64(v) => DecodedValue {
268            type_name: "Counter64".into(),
269            display: v.to_string(),
270            json_value: (*v).into(),
271            formatted: None,
272            raw_hex: None,
273            size: None,
274        },
275
276        Value::NoSuchObject => DecodedValue {
277            type_name: "NoSuchObject".into(),
278            display: "No Such Object available".into(),
279            json_value: serde_json::Value::Null,
280            formatted: Some("No Such Object available".into()),
281            raw_hex: None,
282            size: None,
283        },
284
285        Value::NoSuchInstance => DecodedValue {
286            type_name: "NoSuchInstance".into(),
287            display: "No Such Instance currently exists".into(),
288            json_value: serde_json::Value::Null,
289            formatted: Some("No Such Instance currently exists".into()),
290            raw_hex: None,
291            size: None,
292        },
293
294        Value::EndOfMibView => DecodedValue {
295            type_name: "EndOfMibView".into(),
296            display: "No more variables left in this MIB View".into(),
297            json_value: serde_json::Value::Null,
298            formatted: Some("No more variables left in this MIB View".into()),
299            raw_hex: None,
300            size: None,
301        },
302
303        Value::Unknown { tag, data } => {
304            let compact_hex = hex::encode(data);
305            let spaced_hex = format_hex_string(data);
306            DecodedValue {
307                type_name: format!("Unknown(0x{:02X})", tag),
308                display: spaced_hex.clone(),
309                json_value: serde_json::Value::String(compact_hex.clone()),
310                formatted: Some(spaced_hex),
311                raw_hex: Some(compact_hex),
312                size: Some(data.len()),
313            }
314        }
315    }
316}
317
318/// Write detailed varbind information for verbose output.
319fn write_verbose_varbind<W: Write>(w: &mut W, vb: &VarBind, show_hints: bool) {
320    // OID with optional hint
321    let hint = if show_hints {
322        hints::lookup(&vb.oid)
323    } else {
324        None
325    };
326    if let Some(h) = hint {
327        let _ = writeln!(w, "  {} ({})", format_oid(&vb.oid), h);
328    } else {
329        let _ = writeln!(w, "  {}", format_oid(&vb.oid));
330    }
331
332    let decoded = decode_value(&vb.value, false);
333
334    let _ = writeln!(w, "    Type:    {}", decoded.type_name);
335    let _ = writeln!(w, "    Value:   {}", decoded.display);
336
337    if let Some(ref raw) = decoded.raw_hex {
338        let _ = writeln!(w, "    Raw:     {}", raw);
339    }
340
341    if let Some(s) = decoded.size {
342        let _ = writeln!(w, "    Size:    {} bytes", s);
343    }
344}
345
346/// Result of a GET/WALK operation, ready for output.
347#[derive(Debug, Serialize)]
348pub struct OperationResult {
349    pub target: String,
350    pub version: String,
351    pub results: Vec<VarBindResult>,
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub timing_ms: Option<f64>,
354    #[serde(skip_serializing_if = "Option::is_none")]
355    pub retries: Option<u32>,
356}
357
358/// A single varbind result.
359#[derive(Debug, Serialize)]
360pub struct VarBindResult {
361    pub oid: String,
362    #[serde(skip_serializing_if = "Option::is_none")]
363    pub hint: Option<String>,
364    #[serde(rename = "type")]
365    pub value_type: String,
366    pub value: serde_json::Value,
367    #[serde(skip_serializing_if = "Option::is_none")]
368    pub formatted: Option<String>,
369    #[serde(skip_serializing_if = "Option::is_none")]
370    pub raw_hex: Option<String>,
371}
372
373/// Trait for formatting OIDs and values using external metadata.
374///
375/// Implementors provide symbolic OID formatting and type-aware value rendering.
376/// Used by [`OutputContext`] to produce richer output when available.
377pub trait VarBindFormatter {
378    /// Format a numeric OID symbolically (e.g., "IF-MIB::ifDescr.1").
379    fn format_oid(&self, oid: &Oid) -> String;
380    /// Format a value using type metadata for the given OID.
381    fn format_value(&self, oid: &Oid, value: &Value) -> String;
382}
383
384/// Output context for formatting.
385pub struct OutputContext<'a> {
386    pub format: OutputFormat,
387    pub show_hints: bool,
388    pub force_hex: bool,
389    pub show_timing: bool,
390    /// Optional formatter for symbolic OID names and type-aware values.
391    pub formatter: Option<&'a dyn VarBindFormatter>,
392}
393
394impl<'a> OutputContext<'a> {
395    /// Create a new output context with default settings.
396    pub fn new(format: OutputFormat) -> Self {
397        Self {
398            format,
399            show_hints: true,
400            force_hex: false,
401            show_timing: false,
402            formatter: None,
403        }
404    }
405
406    /// Create an output context from CLI output arguments.
407    pub fn from_args(output: &OutputArgs) -> Self {
408        Self {
409            format: output.format,
410            show_hints: !output.no_hints,
411            force_hex: output.hex,
412            show_timing: output.timing,
413            formatter: None,
414        }
415    }
416
417    /// Write operation results to stdout.
418    pub fn write_results(
419        &self,
420        target: &str,
421        version: Version,
422        varbinds: &[VarBind],
423        elapsed: Option<Duration>,
424        retries: Option<u32>,
425    ) -> io::Result<()> {
426        let result = self.build_result(target, version, varbinds, elapsed, retries);
427        let mut stdout = io::stdout().lock();
428
429        match self.format {
430            OutputFormat::Human => self.write_human(&mut stdout, &result),
431            OutputFormat::Json => self.write_json(&mut stdout, &result),
432            OutputFormat::Raw => self.write_raw(&mut stdout, &result),
433        }
434    }
435
436    fn build_result(
437        &self,
438        target: &str,
439        version: Version,
440        varbinds: &[VarBind],
441        elapsed: Option<Duration>,
442        retries: Option<u32>,
443    ) -> OperationResult {
444        let results = varbinds.iter().map(|vb| self.format_varbind(vb)).collect();
445
446        OperationResult {
447            target: target.to_string(),
448            version: format!("{:?}", version),
449            results,
450            timing_ms: elapsed.map(|d| d.as_secs_f64() * 1000.0),
451            retries,
452        }
453    }
454
455    fn format_varbind(&self, vb: &VarBind) -> VarBindResult {
456        if let Some(fmt) = self.formatter {
457            return self.format_varbind_with_formatter(fmt, vb);
458        }
459
460        let oid_str = format_oid(&vb.oid);
461        let hint = if self.show_hints {
462            hints::lookup(&vb.oid).map(String::from)
463        } else {
464            None
465        };
466
467        let decoded = decode_value(&vb.value, self.force_hex);
468
469        VarBindResult {
470            oid: oid_str,
471            hint,
472            value_type: decoded.type_name,
473            value: decoded.json_value,
474            formatted: decoded.formatted,
475            raw_hex: decoded.raw_hex,
476        }
477    }
478
479    fn format_varbind_with_formatter(
480        &self,
481        fmt: &dyn VarBindFormatter,
482        vb: &VarBind,
483    ) -> VarBindResult {
484        let oid_str = fmt.format_oid(&vb.oid);
485        let formatted_value = fmt.format_value(&vb.oid, &vb.value);
486        let decoded = decode_value(&vb.value, self.force_hex);
487
488        VarBindResult {
489            oid: oid_str,
490            hint: None, // Formatter provides the OID name directly
491            value_type: decoded.type_name,
492            value: decoded.json_value,
493            formatted: Some(formatted_value),
494            raw_hex: decoded.raw_hex,
495        }
496    }
497
498    fn write_human<W: Write>(&self, w: &mut W, result: &OperationResult) -> io::Result<()> {
499        for vb in &result.results {
500            // OID with optional hint
501            if let Some(ref hint) = vb.hint {
502                write!(w, "{} ({})", vb.oid, hint)?;
503            } else {
504                write!(w, "{}", vb.oid)?;
505            }
506
507            // Type and value
508            write!(w, " = {}: ", vb.value_type)?;
509
510            // Value - prefer formatted for display
511            if let Some(ref formatted) = vb.formatted {
512                writeln!(w, "{}", formatted)?;
513            } else {
514                match &vb.value {
515                    serde_json::Value::String(s) => writeln!(w, "\"{}\"", s)?,
516                    serde_json::Value::Null => writeln!(w)?,
517                    other => writeln!(w, "{}", other)?,
518                }
519            }
520        }
521
522        if self.show_timing
523            && let Some(ms) = result.timing_ms
524        {
525            if let Some(retries) = result.retries {
526                writeln!(w, "\nTiming: {:.1}ms ({} retries)", ms, retries)?;
527            } else {
528                writeln!(w, "\nTiming: {:.1}ms", ms)?;
529            }
530        }
531
532        Ok(())
533    }
534
535    fn write_json<W: Write>(&self, w: &mut W, result: &OperationResult) -> io::Result<()> {
536        let json = serde_json::to_string_pretty(result).map_err(io::Error::other)?;
537        writeln!(w, "{}", json)
538    }
539
540    fn write_raw<W: Write>(&self, w: &mut W, result: &OperationResult) -> io::Result<()> {
541        for vb in &result.results {
542            let value_str = match &vb.value {
543                serde_json::Value::String(s) => s.clone(),
544                serde_json::Value::Null => String::new(),
545                other => other.to_string(),
546            };
547            writeln!(w, "{}\t{}", vb.oid, value_str)?;
548        }
549        Ok(())
550    }
551}
552
553/// Format an OID as dotted string.
554fn format_oid(oid: &Oid) -> String {
555    oid.arcs()
556        .iter()
557        .map(|a| a.to_string())
558        .collect::<Vec<_>>()
559        .join(".")
560}
561
562/// Format bytes as spaced hex for display.
563fn format_hex_string(bytes: &[u8]) -> String {
564    crate::format::format_hex_display(bytes)
565}
566
567/// Format TimeTicks as human-readable duration.
568fn format_timeticks(centiseconds: u32) -> String {
569    crate::format::format_timeticks(centiseconds)
570}
571
572/// Build a SecurityInfo from CLI arguments.
573pub fn build_security_info(v3: &V3Args, common: &CommonArgs) -> SecurityInfo {
574    if v3.is_v3() {
575        SecurityInfo::V3 {
576            username: v3.username.clone().unwrap_or_default(),
577            auth_protocol: v3.auth_protocol.map(|p| format!("{}", p)),
578            priv_protocol: v3.priv_protocol.map(|p| format!("{}", p)),
579        }
580    } else {
581        SecurityInfo::Community(common.community.clone())
582    }
583}
584
585/// Write an error message to stderr.
586pub fn write_error(err: &crate::Error) {
587    eprintln!("Error: {}", err);
588}
589
590#[cfg(test)]
591mod tests {
592    use super::*;
593
594    #[test]
595    fn test_format_timeticks() {
596        // 1 day, 10 hours, 17 minutes, 36.78 seconds = 123456.78 seconds = 12345678 centiseconds
597        assert_eq!(format_timeticks(12345678), "1d 10:17:36.78");
598
599        // Less than a day
600        assert_eq!(format_timeticks(360000), "01:00:00.00");
601
602        // Zero
603        assert_eq!(format_timeticks(0), "00:00:00.00");
604    }
605
606    #[test]
607    fn test_is_printable() {
608        assert!(hex::is_printable(b"Hello World"));
609        assert!(hex::is_printable(b"Line 1\nLine 2"));
610        assert!(hex::is_printable(b""));
611        assert!(!hex::is_printable(&[0x00, 0x01, 0x02]));
612        assert!(!hex::is_printable(&[0x80, 0x81]));
613    }
614
615    #[test]
616    fn test_hex_encode() {
617        assert_eq!(hex::encode(&[0x00, 0x1A, 0x2B]), "001a2b");
618    }
619
620    #[test]
621    fn test_format_hex_string() {
622        assert_eq!(format_hex_string(&[0x00, 0x1A, 0x2B]), "00 1A 2B");
623    }
624}