Skip to main content

heddle_cli_render/cli/render/
query.rs

1// SPDX-License-Identifier: Apache-2.0
2use anyhow::{Context, Result};
3use chrono::{TimeZone, Utc};
4use verbs::QueryReport;
5
6use crate::cli::{render::write_stdout, style};
7
8pub fn query_json(report: &QueryReport) -> Result<()> {
9    let mut text = serde_json::to_string(report).context("serialize query output")?;
10    text.push('\n');
11    write_stdout(&text)
12}
13
14pub fn query_text(report: &QueryReport) -> Result<()> {
15    write_stdout(&format_query_text(report))
16}
17
18fn format_query_text(report: &QueryReport) -> String {
19    let mut text = String::new();
20    if report.hits.is_empty() {
21        text.push_str("(no matches)\n");
22    } else {
23        for hit in &report.hits {
24            let ts = Utc
25                .timestamp_opt(hit.timestamp_secs, 0)
26                .single()
27                .map(|d| d.to_rfc3339())
28                .unwrap_or_else(|| hit.timestamp_secs.to_string());
29            text.push_str(&format!(
30                "#{} {} {} <{}>",
31                hit.seq,
32                ts,
33                style::human_text(&hit.verb),
34                style::human_text(&hit.actor_email)
35            ));
36            if let Some(thread) = &hit.thread {
37                text.push_str(&format!(" thread={}", style::thread_label(thread, None)));
38            }
39            if let Some(state_id) = &hit.state_id {
40                text.push_str(&format!(" -> {}", style::human_text(state_id)));
41            }
42            text.push('\n');
43        }
44    }
45    text
46}
47
48#[cfg(test)]
49mod tests {
50    use verbs::QueryHit;
51
52    use super::*;
53
54    #[test]
55    fn text_renderer_consumes_the_typed_query_report() {
56        let report = QueryReport {
57            output_kind: "query",
58            hits: vec![QueryHit {
59                seq: 7,
60                timestamp_secs: 0,
61                verb: "snapshot".to_string(),
62                actor_email: "agent@example.com".to_string(),
63                operation_id: None,
64                thread: Some("agent/facade".to_string()),
65                symbols: Vec::new(),
66                signal_kinds: Vec::new(),
67                state_id: Some("hs-123".to_string()),
68            }],
69        };
70
71        assert_eq!(
72            format_query_text(&report),
73            "#7 1970-01-01T00:00:00+00:00 snapshot <agent@example.com> thread=agent/facade -> hs-123\n"
74        );
75    }
76
77    #[test]
78    fn text_renderer_has_a_stable_empty_report() {
79        let report = QueryReport {
80            output_kind: "query",
81            hits: Vec::new(),
82        };
83        assert_eq!(format_query_text(&report), "(no matches)\n");
84    }
85
86    #[test]
87    fn text_renderer_hides_full_thread_and_state_ids() {
88        let id = "a".repeat(64);
89        let report = QueryReport {
90            output_kind: "query",
91            hits: vec![QueryHit {
92                seq: 1,
93                timestamp_secs: 0,
94                verb: "capture".to_string(),
95                actor_email: "agent@example.com".to_string(),
96                operation_id: None,
97                thread: Some(id.clone()),
98                symbols: Vec::new(),
99                signal_kinds: Vec::new(),
100                state_id: Some(id.clone()),
101            }],
102        };
103        let text = format_query_text(&report);
104        assert!(!text.contains(&id), "{text}");
105        assert!(serde_json::to_string(&report).unwrap().contains(&id));
106    }
107}