1use std::io::{self, Write};
4
5use crossterm::style::{Color, ResetColor, SetForegroundColor};
6
7use crate::query::EventSource;
8use crate::timeline::{NormalizedVerdict, TimelineEvent};
9
10#[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
28pub 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
43fn render_table(events: &[TimelineEvent], color: bool, out: &mut dyn Write) -> io::Result<()> {
45 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
126pub 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 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 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 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 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 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 let result = truncate_str("café", 4);
393 assert_eq!(result, "caf");
394
395 let result = truncate_str("日本語テスト", 5);
397 assert_eq!(result, "日"); let result = truncate_str("🚀🎉", 5);
401 assert_eq!(result, "🚀"); }
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 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 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 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 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}