1#![cfg_attr(not(feature = "std"), no_std)]
2#[cfg(not(feature = "std"))]
3extern crate alloc;
4
5mod sync_compat;
6
7use core::sync::atomic::{AtomicUsize, Ordering};
8use cu29_clock::RobotClock;
9use cu29_log::CuLogEntry;
10#[allow(unused_imports)]
11use cu29_log::CuLogLevel;
12use cu29_traits::{CuResult, WriteStream};
13use log::Log;
14use sync_compat::{Mutex, OnceLock, init_once, lock as lock_mutex};
15
16#[cfg(not(feature = "std"))]
17mod imp {
18 pub use alloc::boxed::Box;
19 pub use alloc::vec::Vec;
20}
21
22#[cfg(feature = "std")]
23mod imp {
24 pub use bincode::config::Configuration;
25 pub use bincode::enc::Encode;
26 pub use bincode::enc::Encoder;
27 pub use bincode::enc::EncoderImpl;
28 pub use bincode::enc::write::Writer;
29 pub use bincode::error::EncodeError;
30 pub use std::fmt::{Debug, Formatter};
31 pub use std::fs::File;
32 pub use std::io::{BufWriter, Write};
33 pub use std::path::PathBuf;
34
35 #[cfg(debug_assertions)]
36 pub use {std::collections::HashMap, strfmt::strfmt};
37}
38
39use imp::*;
40
41#[allow(dead_code)] #[derive(Debug)]
43struct DummyWriteStream;
44
45impl WriteStream<CuLogEntry> for DummyWriteStream {
46 #[allow(unused_variables)] fn log(&mut self, obj: &CuLogEntry) -> CuResult<()> {
48 #[cfg(feature = "std")]
49 eprintln!("Pending logs got cut: {obj:?}");
50 Ok(())
51 }
52}
53type LogWriter = Box<dyn WriteStream<CuLogEntry> + Send + 'static>;
54
55pub type LiveLogListener = Box<dyn Fn(&CuLogEntry, &str, &[&str]) + Send + Sync + 'static>;
57
58pub type LiveLogListenerId = usize;
59
60struct LiveLogListeners {
61 next_id: LiveLogListenerId,
62 listeners: Vec<(LiveLogListenerId, LiveLogListener)>,
63}
64
65impl Default for LiveLogListeners {
66 fn default() -> Self {
67 Self {
68 next_id: 1,
69 listeners: Vec::new(),
70 }
71 }
72}
73
74impl LiveLogListeners {
75 fn insert(&mut self, listener: LiveLogListener) -> LiveLogListenerId {
76 let id = self.next_id;
77 self.next_id = self.next_id.saturating_add(1).max(1);
78 self.listeners.push((id, listener));
79 id
80 }
81
82 fn remove(&mut self, id: LiveLogListenerId) {
83 self.listeners.retain(|(listener_id, _)| *listener_id != id);
84 }
85}
86
87pub struct LiveLogListenerGuard {
88 id: Option<LiveLogListenerId>,
89}
90
91impl Drop for LiveLogListenerGuard {
92 fn drop(&mut self) {
93 if let Some(id) = self.id.take() {
94 unregister_live_log_listener_id(id);
95 }
96 }
97}
98
99#[cfg(all(feature = "std", debug_assertions))]
100pub fn format_message_only(
101 format_str: &str,
102 params: &[String],
103 named_params: &HashMap<String, String>,
104) -> CuResult<String> {
105 if format_str.contains("{}") {
106 let mut formatted = format_str.to_string();
107 for param in params.iter() {
108 if !formatted.contains("{}") {
109 break;
110 }
111 formatted = formatted.replacen("{}", param, 1);
112 }
113 if !named_params.is_empty() {
114 let mut named = named_params.iter().collect::<Vec<_>>();
115 named.sort_by(|a, b| a.0.cmp(b.0));
116 for (name, value) in named {
117 if formatted.contains("{}") {
118 formatted = formatted.replacen("{}", value, 1);
119 }
120 formatted = formatted.replace(&format!("{{{name}}}"), value);
121 }
122 }
123 return Ok(formatted);
124 }
125
126 imp::strfmt(format_str, named_params).map_err(|e| {
128 cu29_traits::CuError::new_with_cause(
129 format!(
130 "Failed to format log message: {format_str:?} with variables [{named_params:?}]"
131 )
132 .as_str(),
133 e,
134 )
135 })
136}
137
138struct LoggerState {
140 writer: Mutex<LogWriter>,
141 clock: RobotClock,
142 live_listeners: Mutex<LiveLogListeners>,
143}
144
145impl core::fmt::Debug for LoggerState {
146 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
147 f.debug_struct("LoggerState")
148 .field("clock", &self.clock)
149 .finish_non_exhaustive()
150 }
151}
152
153static LOGGER_STATE: OnceLock<LoggerState> = OnceLock::new();
154static STRUCTURED_LOG_BYTES: AtomicUsize = AtomicUsize::new(0);
155
156fn init_logger_state(state: LoggerState) {
157 init_once(&LOGGER_STATE, state);
158}
159
160pub struct NullLog;
161impl Log for NullLog {
162 fn enabled(&self, _metadata: &log::Metadata) -> bool {
163 false
164 }
165
166 fn log(&self, _record: &log::Record) {}
167 fn flush(&self) {}
168}
169
170pub struct LoggerRuntime {}
172
173impl LoggerRuntime {
174 pub fn init(
177 clock: RobotClock,
178 destination: impl WriteStream<CuLogEntry> + 'static,
179 #[allow(unused_variables)] extra_text_logger: Option<impl Log + 'static>,
180 ) -> Self {
181 STRUCTURED_LOG_BYTES.store(0, Ordering::Relaxed);
182
183 if let Some(state) = LOGGER_STATE.get() {
184 let mut writer_guard = lock_mutex(&state.writer);
185 *writer_guard = Box::new(destination);
186 } else {
187 let state = LoggerState {
188 writer: Mutex::new(Box::new(destination)),
189 clock,
190 live_listeners: Mutex::new(LiveLogListeners::default()),
191 };
192 init_logger_state(state);
193 }
194
195 #[cfg(all(feature = "std", debug_assertions))]
197 if let Some(logger) = extra_text_logger {
198 register_live_log_listener(move |entry, format_str, param_names| {
199 let params: Vec<String> = entry.params.iter().map(|v| v.to_string()).collect();
201 let mut named_params = HashMap::new();
202 let mut param_names_iter = param_names.iter();
203 for (name_index, value) in entry.paramname_indexes.iter().zip(params.iter()) {
204 if *name_index != cu29_log::ANONYMOUS {
205 let Some(name) = param_names_iter.next() else {
206 continue;
207 };
208 named_params.insert(name.to_string(), value.clone());
209 }
210 }
211 if let Ok(line) = format_message_only(format_str, params.as_slice(), &named_params)
212 {
213 logger.log(
214 &log::Record::builder()
215 .args(format_args!("{line}"))
216 .level(match entry.level {
217 CuLogLevel::Debug => log::Level::Debug,
218 CuLogLevel::Info => log::Level::Info,
219 CuLogLevel::Warning => log::Level::Warn,
220 CuLogLevel::Error => log::Level::Error,
221 CuLogLevel::Critical => log::Level::Error,
222 })
223 .target("cu29_log")
224 .module_path_static(Some("cu29_log"))
225 .file_static(Some("cu29_log"))
226 .line(Some(0))
227 .build(),
228 );
229 }
230 });
231 }
232
233 LoggerRuntime {}
234 }
235
236 pub fn flush(&self) {
237 if let Some(state) = LOGGER_STATE.get() {
239 let mut writer = lock_mutex(&state.writer);
240 let _ = writer.flush(); } else {
242 #[cfg(feature = "std")]
243 eprintln!("cu29_log: Logger not initialized.");
244 }
245 }
246}
247
248impl Drop for LoggerRuntime {
249 fn drop(&mut self) {
250 self.flush();
251 if let Some(state) = LOGGER_STATE.get() {
253 let mut writer_guard = lock_mutex(&state.writer);
254 *writer_guard = Box::new(DummyWriteStream);
255 }
256 }
257}
258
259#[inline(always)]
262fn log_inner(
263 entry: &mut CuLogEntry,
264 notify: bool,
265 format_str: &str,
266 param_names: &[&str],
267) -> CuResult<()> {
268 let Some(state) = LOGGER_STATE.get() else {
269 return Err("Logger not initialized.".into());
270 };
271 entry.time = state.clock.now();
272
273 let mut guard = lock_mutex(&state.writer);
274 guard.log(entry)?;
275 if let Some(bytes) = guard.last_log_bytes() {
276 STRUCTURED_LOG_BYTES.fetch_add(bytes, Ordering::Relaxed);
277 }
278
279 if notify {
281 notify_live_listeners(entry, format_str, param_names);
282 }
283 Ok(())
284}
285
286#[inline(always)]
288pub fn log(entry: &mut CuLogEntry) -> CuResult<()> {
289 log_inner(entry, true, "", &[])
290}
291
292pub fn structured_log_bytes_total() -> u64 {
294 STRUCTURED_LOG_BYTES.load(Ordering::Relaxed) as u64
295}
296
297#[cfg(debug_assertions)]
300pub fn log_debug_mode(
301 entry: &mut CuLogEntry,
302 _format_str: &str, _param_names: &[&str],
304) -> CuResult<()> {
305 log_inner(entry, false, "", &[])?;
307
308 #[cfg(feature = "std")]
310 extra_log(entry, _format_str, _param_names)?;
311
312 Ok(())
313}
314
315#[cfg(debug_assertions)]
316#[cfg(feature = "std")]
317fn extra_log(entry: &mut CuLogEntry, format_str: &str, param_names: &[&str]) -> CuResult<()> {
318 notify_live_listeners(entry, format_str, param_names);
320
321 Ok(())
322}
323
324pub fn register_live_log_listener<F>(cb: F) -> Option<LiveLogListenerId>
326where
327 F: Fn(&CuLogEntry, &str, &[&str]) + Send + Sync + 'static,
328{
329 LOGGER_STATE.get().map(|state| {
330 let mut guard = lock_mutex(&state.live_listeners);
331 guard.insert(Box::new(cb))
332 })
333}
334
335pub fn scoped_live_log_listener<F>(cb: F) -> LiveLogListenerGuard
337where
338 F: Fn(&CuLogEntry, &str, &[&str]) + Send + Sync + 'static,
339{
340 LiveLogListenerGuard {
341 id: register_live_log_listener(cb),
342 }
343}
344
345pub fn unregister_live_log_listener_id(id: LiveLogListenerId) {
347 if let Some(state) = LOGGER_STATE.get() {
348 let mut guard = lock_mutex(&state.live_listeners);
349 guard.remove(id);
350 }
351}
352
353pub fn unregister_live_log_listener() {
355 if let Some(state) = LOGGER_STATE.get() {
356 let mut guard = lock_mutex(&state.live_listeners);
357 guard.listeners.clear();
358 }
359}
360
361#[cfg(feature = "std")]
366pub fn capture_live_logs<F, R>(f: F) -> (R, Vec<CuLogEntry>)
367where
368 F: FnOnce() -> R,
369{
370 let captured_logs = std::sync::Arc::new(std::sync::Mutex::new(Vec::<CuLogEntry>::new()));
371 let listener_guard = LOGGER_STATE.get().map(|_| {
372 let captured_logs_for_listener = captured_logs.clone();
373 scoped_live_log_listener(move |entry, _, _| {
374 captured_logs_for_listener
375 .lock()
376 .expect("live log capture poisoned")
377 .push(entry.clone());
378 })
379 });
380
381 let result = f();
382 drop(listener_guard);
383 let logs = captured_logs
384 .lock()
385 .expect("live log capture poisoned")
386 .clone();
387
388 (result, logs)
389}
390
391#[allow(clippy::collapsible_if)]
393pub(crate) fn notify_live_listeners(entry: &CuLogEntry, format_str: &str, param_names: &[&str]) {
394 if let Some(state) = LOGGER_STATE.get() {
395 let guard = lock_mutex(&state.live_listeners);
396 for (_, cb) in &guard.listeners {
397 cb(entry, format_str, param_names);
398 }
399 }
400}
401#[cfg(feature = "std")]
404pub struct OwningIoWriter<W: Write> {
405 writer: BufWriter<W>,
406 bytes_written: usize,
407}
408
409#[cfg(feature = "std")]
410impl<W: Write> OwningIoWriter<W> {
411 pub fn new(writer: W) -> Self {
412 Self {
413 writer: BufWriter::new(writer),
414 bytes_written: 0,
415 }
416 }
417
418 pub fn bytes_written(&self) -> usize {
419 self.bytes_written
420 }
421
422 pub fn flush(&mut self) -> Result<(), EncodeError> {
423 self.writer.flush().map_err(|inner| EncodeError::Io {
424 inner,
425 index: self.bytes_written,
426 })
427 }
428}
429
430#[cfg(feature = "std")]
431impl<W: Write> Writer for OwningIoWriter<W> {
432 #[inline(always)]
433 fn write(&mut self, bytes: &[u8]) -> Result<(), EncodeError> {
434 self.writer
435 .write_all(bytes)
436 .map_err(|inner| EncodeError::Io {
437 inner,
438 index: self.bytes_written,
439 })?;
440 self.bytes_written += bytes.len();
441 Ok(())
442 }
443}
444
445#[cfg(feature = "std")]
447pub struct SimpleFileWriter {
448 path: PathBuf,
449 encoder: EncoderImpl<OwningIoWriter<File>, Configuration>,
450}
451
452#[cfg(feature = "std")]
453impl SimpleFileWriter {
454 pub fn new(path: &PathBuf) -> CuResult<Self> {
455 let file = std::fs::OpenOptions::new()
456 .create(true)
457 .truncate(true)
458 .write(true)
459 .open(path)
460 .map_err(|e| format!("Failed to open file: {e:?}"))?;
461
462 let writer = OwningIoWriter::new(file);
463 let encoder = EncoderImpl::new(writer, bincode::config::standard());
464
465 Ok(SimpleFileWriter {
466 path: path.clone(),
467 encoder,
468 })
469 }
470}
471
472#[cfg(feature = "std")]
473impl Debug for SimpleFileWriter {
474 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
475 write!(f, "SimpleFileWriter for path {:?}", self.path)
476 }
477}
478
479#[cfg(feature = "std")]
480impl WriteStream<CuLogEntry> for SimpleFileWriter {
481 #[inline(always)]
482 fn log(&mut self, obj: &CuLogEntry) -> CuResult<()> {
483 obj.encode(&mut self.encoder)
484 .map_err(|e| format!("Failed to write to file: {e:?}"))?;
485 Ok(())
486 }
487
488 fn flush(&mut self) -> CuResult<()> {
489 self.encoder
490 .writer()
491 .flush()
492 .map_err(|e| format!("Failed to flush file: {e:?}"))?;
493 Ok(())
494 }
495}
496
497#[cfg(test)]
498mod tests {
499 use crate::CuLogEntry;
500 use bincode::config::standard;
501 use cu29_log::{CuLogLevel, CuLogOrigin};
502 use cu29_value::Value;
503 use smallvec::smallvec;
504
505 #[cfg(not(feature = "std"))]
506 use alloc::string::ToString;
507
508 #[test]
509 fn test_encode_decode_structured_log() {
510 let log_entry = CuLogEntry {
511 time: 0.into(),
512 level: CuLogLevel::Info,
513 origin: CuLogOrigin::default(),
514 msg_index: 1,
515 paramname_indexes: smallvec![2, 3],
516 params: smallvec![Value::String("test".to_string())],
517 };
518 let encoded = bincode::encode_to_vec(&log_entry, standard()).unwrap();
519 let decoded_tuple: (CuLogEntry, usize) =
520 bincode::decode_from_slice(&encoded, standard()).unwrap();
521 assert_eq!(log_entry, decoded_tuple.0);
522 }
523
524 #[cfg(all(feature = "std", debug_assertions))]
525 #[test]
526 fn test_format_message_only_mixes_named_and_positional_placeholders() {
527 let params = vec!["event payload".to_string()];
528 let mut named_params = std::collections::HashMap::new();
529 named_params.insert("hash".to_string(), "0x000000000".to_string());
530 named_params.insert("size".to_string(), "420".to_string());
531
532 let formatted = crate::format_message_only(
533 "File closed after hash was calculated Hash: {hash}, size: {size};\n{}",
534 ¶ms,
535 &named_params,
536 )
537 .unwrap();
538
539 assert_eq!(
540 formatted,
541 "File closed after hash was calculated Hash: 0x000000000, size: 420;\nevent payload"
542 );
543 }
544}