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}