Skip to main content

hunt_query/
render.rs

1//! Terminal rendering for hunt query results and timelines.
2
3use std::io::{self, Write};
4
5use crossterm::style::{Color, ResetColor, SetForegroundColor};
6
7use crate::query::EventSource;
8use crate::timeline::{NormalizedVerdict, TimelineEvent};
9
10/// Configuration for output rendering.
11#[derive(Debug, Clone)]
12pub struct RenderConfig {
13    pub color: bool,
14    pub json: bool,
15    pub jsonl: bool,
16}
17
18impl Default for RenderConfig {
19    fn default() -> Self {
20        Self {
21            color: true,
22            json: false,
23            jsonl: false,
24        }
25    }
26}
27
28/// Render events according to the config.
29pub fn render_events(
30    events: &[TimelineEvent],
31    config: &RenderConfig,
32    out: &mut dyn Write,
33) -> io::Result<()> {
34    if config.json {
35        render_json(events, out)
36    } else if config.jsonl {
37        render_jsonl(events, out)
38    } else {
39        render_table(events, config.color, out)
40    }
41}
42
43/// Render as a formatted table with optional color.
44fn render_table(events: &[TimelineEvent], color: bool, out: &mut dyn Write) -> io::Result<()> {
45    // Header
46    writeln!(
47        out,
48        "{:<24} {:<10} {:<14} {:<10} SUMMARY",
49        "TIMESTAMP", "SOURCE", "KIND", "VERDICT",
50    )?;
51    writeln!(out, "{}", "-".repeat(80))?;
52
53    for event in events {
54        let ts = event.timestamp.format("%Y-%m-%d %H:%M:%S UTC");
55        let source_str = format!("{}", event.source);
56        let kind_str = format!("{}", event.kind);
57        let verdict_str = format!("{}", event.verdict);
58        let summary = truncate_str(&event.summary, 40);
59
60        if color {
61            let sc = source_color(&event.source);
62            let vc = verdict_color(&event.verdict);
63
64            write!(out, "{:<24} ", ts)?;
65            write!(
66                out,
67                "{}{:<10}{} ",
68                SetForegroundColor(sc),
69                source_str,
70                ResetColor
71            )?;
72            write!(out, "{:<14} ", kind_str)?;
73            write!(
74                out,
75                "{}{:<10}{} ",
76                SetForegroundColor(vc),
77                verdict_str,
78                ResetColor
79            )?;
80            writeln!(out, "{}", summary)?;
81        } else {
82            writeln!(
83                out,
84                "{:<24} {:<10} {:<14} {:<10} {}",
85                ts, source_str, kind_str, verdict_str, summary
86            )?;
87        }
88    }
89
90    Ok(())
91}
92
93fn source_color(source: &EventSource) -> Color {
94    match source {
95        EventSource::Tetragon => Color::Cyan,
96        EventSource::Hubble => Color::Blue,
97        EventSource::Receipt => Color::Magenta,
98        EventSource::Scan => Color::White,
99    }
100}
101
102fn verdict_color(verdict: &NormalizedVerdict) -> Color {
103    match verdict {
104        NormalizedVerdict::Allow => Color::Green,
105        NormalizedVerdict::Deny => Color::Red,
106        NormalizedVerdict::Warn => Color::Yellow,
107        NormalizedVerdict::Forwarded => Color::Green,
108        NormalizedVerdict::Dropped => Color::Red,
109        NormalizedVerdict::None => Color::White,
110    }
111}
112
113fn render_json(events: &[TimelineEvent], out: &mut dyn Write) -> io::Result<()> {
114    let json_str = serde_json::to_string_pretty(events).map_err(io::Error::other)?;
115    writeln!(out, "{json_str}")
116}
117
118fn render_jsonl(events: &[TimelineEvent], out: &mut dyn Write) -> io::Result<()> {
119    for event in events {
120        let line = serde_json::to_string(event).map_err(io::Error::other)?;
121        writeln!(out, "{line}")?;
122    }
123    Ok(())
124}
125
126/// Render a timeline header with entity info.
127pub fn render_timeline_header(
128    entity: Option<&str>,
129    event_count: usize,
130    sources: &[EventSource],
131    out: &mut dyn Write,
132) -> io::Result<()> {
133    if let Some(name) = entity {
134        writeln!(out, "Timeline for: {name}")?;
135    }
136    let source_names: Vec<String> = sources.iter().map(|s| format!("{s}")).collect();
137    writeln!(
138        out,
139        "Events: {event_count} | Sources: {}",
140        source_names.join(", ")
141    )?;
142    writeln!(out)
143}
144
145fn truncate_str(s: &str, max_len: usize) -> &str {
146    if s.len() <= max_len {
147        s
148    } else {
149        // Find a char boundary at or before max_len to avoid panicking on
150        // multi-byte UTF-8 sequences.
151        let mut end = max_len;
152        while end > 0 && !s.is_char_boundary(end) {
153            end -= 1;
154        }
155        &s[..end]
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use crate::query::EventSource;
163    use crate::timeline::{NormalizedVerdict, TimelineEvent, TimelineEventKind};
164    use chrono::TimeZone;
165    use chrono::Utc;
166
167    fn make_event() -> TimelineEvent {
168        TimelineEvent {
169            timestamp: Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap(),
170            source: EventSource::Tetragon,
171            kind: TimelineEventKind::ProcessExec,
172            verdict: NormalizedVerdict::Allow,
173            severity: None,
174            summary: "process_exec /usr/bin/curl".to_string(),
175            process: Some("/usr/bin/curl".to_string()),
176            namespace: Some("default".to_string()),
177            pod: Some("agent-pod-abc123".to_string()),
178            action_type: Some("process".to_string()),
179            signature_valid: None,
180            raw: None,
181        }
182    }
183
184    fn make_deny_event() -> TimelineEvent {
185        TimelineEvent {
186            timestamp: Utc.with_ymd_and_hms(2025, 6, 15, 12, 5, 0).unwrap(),
187            source: EventSource::Receipt,
188            kind: TimelineEventKind::GuardDecision,
189            verdict: NormalizedVerdict::Deny,
190            severity: Some("high".to_string()),
191            summary: "shell_exec blocked: rm -rf /".to_string(),
192            process: Some("bash".to_string()),
193            namespace: Some("production".to_string()),
194            pod: Some("worker-pod-xyz".to_string()),
195            action_type: Some("shell".to_string()),
196            signature_valid: Some(true),
197            raw: None,
198        }
199    }
200
201    #[test]
202    fn render_table_no_color_output() {
203        let events = vec![make_event()];
204        let config = RenderConfig {
205            color: false,
206            json: false,
207            jsonl: false,
208        };
209        let mut buf = Vec::new();
210        render_events(&events, &config, &mut buf).unwrap();
211        let output = String::from_utf8(buf).unwrap();
212
213        assert!(output.contains("TIMESTAMP"));
214        assert!(output.contains("SOURCE"));
215        assert!(output.contains("KIND"));
216        assert!(output.contains("VERDICT"));
217        assert!(output.contains("SUMMARY"));
218        assert!(output.contains("tetragon"));
219        assert!(output.contains("process_exec"));
220        assert!(output.contains("allow"));
221        assert!(output.contains("process_exec /usr/bin/curl"));
222    }
223
224    #[test]
225    fn render_table_with_color_contains_ansi() {
226        let events = vec![make_event()];
227        let config = RenderConfig {
228            color: true,
229            json: false,
230            jsonl: false,
231        };
232        let mut buf = Vec::new();
233        render_events(&events, &config, &mut buf).unwrap();
234        let output = String::from_utf8(buf).unwrap();
235
236        // ANSI escape codes start with ESC[
237        assert!(output.contains("\x1b["), "should contain ANSI escape codes");
238        assert!(output.contains("tetragon"));
239        assert!(output.contains("allow"));
240    }
241
242    #[test]
243    fn render_table_multiple_events() {
244        let events = vec![make_event(), make_deny_event()];
245        let config = RenderConfig {
246            color: false,
247            json: false,
248            jsonl: false,
249        };
250        let mut buf = Vec::new();
251        render_events(&events, &config, &mut buf).unwrap();
252        let output = String::from_utf8(buf).unwrap();
253
254        assert!(output.contains("tetragon"));
255        assert!(output.contains("receipt"));
256        assert!(output.contains("allow"));
257        assert!(output.contains("deny"));
258    }
259
260    #[test]
261    fn render_table_empty_events() {
262        let events: Vec<TimelineEvent> = vec![];
263        let config = RenderConfig {
264            color: false,
265            json: false,
266            jsonl: false,
267        };
268        let mut buf = Vec::new();
269        render_events(&events, &config, &mut buf).unwrap();
270        let output = String::from_utf8(buf).unwrap();
271
272        // Should still have headers
273        assert!(output.contains("TIMESTAMP"));
274        assert!(output.contains("SOURCE"));
275    }
276
277    #[test]
278    fn render_json_output() {
279        let events = vec![make_event()];
280        let config = RenderConfig {
281            color: false,
282            json: true,
283            jsonl: false,
284        };
285        let mut buf = Vec::new();
286        render_events(&events, &config, &mut buf).unwrap();
287        let output = String::from_utf8(buf).unwrap();
288
289        // Should be valid JSON array
290        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
291        assert!(parsed.is_array());
292        assert_eq!(parsed.as_array().unwrap().len(), 1);
293    }
294
295    #[test]
296    fn render_json_empty_events() {
297        let events: Vec<TimelineEvent> = vec![];
298        let config = RenderConfig {
299            color: false,
300            json: true,
301            jsonl: false,
302        };
303        let mut buf = Vec::new();
304        render_events(&events, &config, &mut buf).unwrap();
305        let output = String::from_utf8(buf).unwrap();
306
307        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
308        assert!(parsed.is_array());
309        assert!(parsed.as_array().unwrap().is_empty());
310    }
311
312    #[test]
313    fn render_jsonl_output() {
314        let events = vec![make_event(), make_deny_event()];
315        let config = RenderConfig {
316            color: false,
317            json: false,
318            jsonl: true,
319        };
320        let mut buf = Vec::new();
321        render_events(&events, &config, &mut buf).unwrap();
322        let output = String::from_utf8(buf).unwrap();
323
324        // Each line should be valid JSON
325        let lines: Vec<&str> = output.trim().split('\n').collect();
326        assert_eq!(lines.len(), 2);
327        for line in lines {
328            let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
329            assert!(parsed.is_object());
330        }
331    }
332
333    #[test]
334    fn render_jsonl_empty_events() {
335        let events: Vec<TimelineEvent> = vec![];
336        let config = RenderConfig {
337            color: false,
338            json: false,
339            jsonl: true,
340        };
341        let mut buf = Vec::new();
342        render_events(&events, &config, &mut buf).unwrap();
343        let output = String::from_utf8(buf).unwrap();
344
345        assert!(output.is_empty());
346    }
347
348    #[test]
349    fn render_timeline_header_with_entity() {
350        let mut buf = Vec::new();
351        let sources = vec![EventSource::Tetragon, EventSource::Receipt];
352        render_timeline_header(Some("agent-1"), 42, &sources, &mut buf).unwrap();
353        let output = String::from_utf8(buf).unwrap();
354
355        assert!(output.contains("Timeline for: agent-1"));
356        assert!(output.contains("Events: 42"));
357        assert!(output.contains("tetragon"));
358        assert!(output.contains("receipt"));
359    }
360
361    #[test]
362    fn render_timeline_header_without_entity() {
363        let mut buf = Vec::new();
364        let sources = vec![EventSource::Hubble];
365        render_timeline_header(None, 10, &sources, &mut buf).unwrap();
366        let output = String::from_utf8(buf).unwrap();
367
368        assert!(!output.contains("Timeline for:"));
369        assert!(output.contains("Events: 10"));
370        assert!(output.contains("hubble"));
371    }
372
373    #[test]
374    fn truncate_str_short() {
375        assert_eq!(truncate_str("hello", 10), "hello");
376    }
377
378    #[test]
379    fn truncate_str_exact() {
380        assert_eq!(truncate_str("hello", 5), "hello");
381    }
382
383    #[test]
384    fn truncate_str_long() {
385        assert_eq!(truncate_str("hello world", 5), "hello");
386    }
387
388    #[test]
389    fn truncate_str_multibyte_utf8() {
390        // "café" is 5 bytes (é = 2 bytes), truncating at byte 4 would
391        // land inside the multi-byte sequence and panic without the fix.
392        let result = truncate_str("café", 4);
393        assert_eq!(result, "caf");
394
395        // CJK characters are 3 bytes each
396        let result = truncate_str("日本語テスト", 5);
397        assert_eq!(result, "日"); // only first char (3 bytes) fits within 5
398
399        // Emoji (4 bytes each)
400        let result = truncate_str("🚀🎉", 5);
401        assert_eq!(result, "🚀"); // only first emoji (4 bytes) fits within 5
402    }
403
404    #[test]
405    fn render_config_default() {
406        let config = RenderConfig::default();
407        assert!(config.color);
408        assert!(!config.json);
409        assert!(!config.jsonl);
410    }
411
412    #[test]
413    fn json_takes_priority_over_table() {
414        // When both json and jsonl are false, table is used
415        let events = vec![make_event()];
416
417        let mut json_buf = Vec::new();
418        let json_config = RenderConfig {
419            color: false,
420            json: true,
421            jsonl: false,
422        };
423        render_events(&events, &json_config, &mut json_buf).unwrap();
424        let json_output = String::from_utf8(json_buf).unwrap();
425
426        // json output should be parseable as JSON array
427        let parsed: serde_json::Value = serde_json::from_str(&json_output).unwrap();
428        assert!(parsed.is_array());
429    }
430
431    #[test]
432    fn json_takes_priority_over_jsonl() {
433        // When both json and jsonl are true, json wins
434        let events = vec![make_event()];
435        let config = RenderConfig {
436            color: false,
437            json: true,
438            jsonl: true,
439        };
440        let mut buf = Vec::new();
441        render_events(&events, &config, &mut buf).unwrap();
442        let output = String::from_utf8(buf).unwrap();
443
444        // Should be a JSON array (not JSONL)
445        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
446        assert!(parsed.is_array());
447    }
448
449    #[test]
450    fn source_colors_are_distinct() {
451        assert_ne!(
452            source_color(&EventSource::Tetragon),
453            source_color(&EventSource::Hubble)
454        );
455        assert_ne!(
456            source_color(&EventSource::Hubble),
457            source_color(&EventSource::Receipt)
458        );
459    }
460
461    #[test]
462    fn verdict_colors_deny_is_red() {
463        assert_eq!(verdict_color(&NormalizedVerdict::Deny), Color::Red);
464        assert_eq!(verdict_color(&NormalizedVerdict::Dropped), Color::Red);
465    }
466
467    #[test]
468    fn verdict_colors_allow_is_green() {
469        assert_eq!(verdict_color(&NormalizedVerdict::Allow), Color::Green);
470        assert_eq!(verdict_color(&NormalizedVerdict::Forwarded), Color::Green);
471    }
472}