Skip to main content

nextdeck_helper/
lib.rs

1use std::{
2    collections::BTreeMap,
3    io::{self, Write},
4    sync::atomic::{AtomicU64, Ordering},
5    time::{SystemTime, UNIX_EPOCH},
6};
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11pub mod xtask;
12
13pub const ENV_VAR: &str = "NEXTDECK_TEST_EVENTS";
14pub const ENV_VALUE: &str = "stdio-v1";
15pub const FRAME_PREFIX: &str = "NEXTDECK_EVENT_V1 ";
16pub const SCHEMA_VERSION: u32 = 1;
17
18static EVENT_SEQUENCE: AtomicU64 = AtomicU64::new(1);
19
20#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
21#[serde(rename_all = "lowercase")]
22pub enum Level {
23    Trace,
24    Debug,
25    Info,
26    Warn,
27    Error,
28}
29
30#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
31pub struct SourceLocation {
32    pub module: String,
33    pub file: String,
34    pub line: u32,
35}
36
37#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
38pub struct TestEvent {
39    pub schema_version: u32,
40    pub sequence: u64,
41    pub time: u64,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub pid: Option<u32>,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub thread: Option<String>,
46    pub level: Level,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub target: Option<String>,
49    pub message: String,
50    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
51    pub fields: BTreeMap<String, Value>,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub source: Option<SourceLocation>,
54}
55
56impl TestEvent {
57    #[must_use]
58    pub fn new(level: Level, message: impl Into<String>) -> Self {
59        Self {
60            schema_version: SCHEMA_VERSION,
61            sequence: EVENT_SEQUENCE.fetch_add(1, Ordering::Relaxed),
62            time: now_millis(),
63            pid: Some(std::process::id()),
64            thread: current_thread_name(),
65            level,
66            target: None,
67            message: message.into(),
68            fields: BTreeMap::new(),
69            source: None,
70        }
71    }
72
73    #[must_use]
74    pub fn with_target(mut self, target: impl Into<String>) -> Self {
75        self.target = Some(target.into());
76        self
77    }
78
79    #[must_use]
80    pub fn with_source(
81        mut self,
82        module: impl Into<String>,
83        file: impl Into<String>,
84        line: u32,
85    ) -> Self {
86        self.source = Some(SourceLocation {
87            module: module.into(),
88            file: file.into(),
89            line,
90        });
91        self
92    }
93
94    #[must_use]
95    pub fn with_fields(mut self, fields: BTreeMap<String, Value>) -> Self {
96        self.fields = fields;
97        self
98    }
99}
100
101#[must_use]
102pub fn enabled() -> bool {
103    std::env::var(ENV_VAR).is_ok_and(|value| value == ENV_VALUE)
104}
105
106pub fn emit(event: &TestEvent) -> io::Result<()> {
107    if !enabled() {
108        return Ok(());
109    }
110    let json = serde_json::to_vec(event).map_err(io::Error::other)?;
111    let mut frame = Vec::with_capacity(FRAME_PREFIX.len() + json.len() + 1);
112    frame.extend_from_slice(FRAME_PREFIX.as_bytes());
113    frame.extend_from_slice(&json);
114    frame.push(b'\n');
115    io::stdout().lock().write_all(&frame)
116}
117
118pub fn emit_with_fields(
119    level: Level,
120    target: Option<String>,
121    message: String,
122    fields: BTreeMap<String, Value>,
123    module: &'static str,
124    file: &'static str,
125    line: u32,
126) -> io::Result<()> {
127    let mut event = TestEvent::new(level, message)
128        .with_fields(fields)
129        .with_source(module, file, line);
130    event.target = target;
131    emit(&event)
132}
133
134#[must_use]
135pub fn now_millis() -> u64 {
136    let millis = SystemTime::now()
137        .duration_since(UNIX_EPOCH)
138        .unwrap_or_default()
139        .as_millis();
140    u64::try_from(millis).unwrap_or(u64::MAX)
141}
142
143#[doc(hidden)]
144pub fn field_value(value: impl Serialize) -> Value {
145    serde_json::to_value(value).unwrap_or_else(|error| {
146        Value::String(format!("<event field serialization failed: {error}>"))
147    })
148}
149
150fn current_thread_name() -> Option<String> {
151    let thread = std::thread::current();
152    thread.name().map(ToOwned::to_owned)
153}
154
155#[macro_export]
156macro_rules! event {
157    (level: $level:expr, target: $target:expr, $message:expr; $($key:literal => $value:expr),* $(,)?) => {{
158        if $crate::enabled() {
159            let mut fields = std::collections::BTreeMap::new();
160            $(
161                fields.insert($key.to_string(), $crate::field_value(&$value));
162            )*
163            let _ = $crate::emit_with_fields(
164                $level,
165                Some(($target).to_string()),
166                ($message).to_string(),
167                fields,
168                module_path!(),
169                file!(),
170                line!(),
171            );
172        }
173    }};
174    (level: $level:expr, target: $target:expr, $message:expr $(,)?) => {{
175        if $crate::enabled() {
176            let _ = $crate::emit_with_fields(
177                $level,
178                Some(($target).to_string()),
179                ($message).to_string(),
180                std::collections::BTreeMap::new(),
181                module_path!(),
182                file!(),
183                line!(),
184            );
185        }
186    }};
187    ($message:expr; $($key:literal => $value:expr),* $(,)?) => {{
188        if $crate::enabled() {
189            let mut fields = std::collections::BTreeMap::new();
190            $(
191                fields.insert($key.to_string(), $crate::field_value(&$value));
192            )*
193            let _ = $crate::emit_with_fields(
194                $crate::Level::Info,
195                Some(module_path!().to_string()),
196                ($message).to_string(),
197                fields,
198                module_path!(),
199                file!(),
200                line!(),
201            );
202        }
203    }};
204    ($message:expr $(,)?) => {{
205        if $crate::enabled() {
206            let _ = $crate::emit_with_fields(
207                $crate::Level::Info,
208                Some(module_path!().to_string()),
209                ($message).to_string(),
210                std::collections::BTreeMap::new(),
211                module_path!(),
212                file!(),
213                line!(),
214            );
215        }
216    }};
217}
218
219#[cfg(feature = "xtask-clap")]
220#[macro_export]
221macro_rules! xtask_clap_info {
222    ($cli:ty) => {{
223        if $crate::xtask::handle_nextdeck_info::<$cli>()? {
224            return Ok(());
225        }
226    }};
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn event_serializes_schema_line() {
235        let event = TestEvent::new(Level::Info, "cache hit")
236            .with_target("artifact-cache")
237            .with_fields(BTreeMap::from([(
238                "key".to_owned(),
239                Value::String("abc".to_owned()),
240            )]))
241            .with_source("demo::tests", "src/lib.rs", 42);
242
243        let json = serde_json::to_string(&event).unwrap();
244
245        assert!(json.contains("\"schema_version\":1"));
246        assert!(json.contains("\"sequence\":"));
247        assert!(json.contains("\"pid\":"));
248        assert!(json.contains("\"level\":\"info\""));
249        assert!(json.contains("\"target\":\"artifact-cache\""));
250        assert!(json.contains("\"message\":\"cache hit\""));
251        assert!(json.contains("\"module\":\"demo::tests\""));
252    }
253
254    #[test]
255    fn event_frame_is_versioned_json() {
256        let event = TestEvent::new(Level::Warn, "slow test");
257        let json = serde_json::to_string(&event).unwrap();
258        let frame = format!("{FRAME_PREFIX}{json}\n");
259
260        assert!(frame.starts_with(FRAME_PREFIX));
261        assert!(frame.ends_with("\n"));
262    }
263
264    #[test]
265    fn event_macro_field_values_do_not_move_callers_values() {
266        let key = String::from("abc");
267
268        event!("cache hit"; "key" => key);
269
270        assert_eq!(key, "abc");
271    }
272}