dirty_debug/
lib.rs

1#![cfg_attr(feature = "fatal-warnings", deny(warnings))]
2// Note: If you change this remember to update `README.md`.  To do so run `cargo rdme`.
3//! `dirty-debug` offers a quick and easy way to log message to a file (or tcp endpoint) for
4//! temporary debugging.
5//!
6//! A simple but powerful way to debug a program is to printing some messages to understand your
7//! code’s behavior.  However, sometimes you don’t have access to the `stdout`/`stderr` streams (for
8//! instance, when your code is loaded and executed by another program).  `dirty-debug` offers you a
9//! simple, no-setup, way to log to a file:
10//!
11//! ```rust,no_run
12//! # use dirty_debug::ddbg;
13//! #
14//! # let state = 42;
15//! #
16//! ddbg!("/tmp/debug_log", "Control reached here.  State={}", state);
17//! ```
18//!
19//! It’s as simple as that.  Every time you call [`ddbg!()`](crate::ddbg) you will append the debug
20//! message to that file, together with the filename and line number of the source code’s location.
21//!
22//! Note that this is not meant to be a normal form of logging: `dirty-debug` should only be used
23//! temporarily during your debug session and discarded after that.
24//!
25//! # Logging to a TCP endpoint
26//!
27//! You can also use `dirty-debug` to log to a TCP endpoint instead of a file:
28//!
29//! ```rust,no_run
30//! # use dirty_debug::ddbg;
31//! #
32//! # let state = 42;
33//! #
34//! ddbg!("tcp://192.168.1.42:12345", "Hello!");
35//! ```
36//!
37//! Probably the easiest way to listen to a TCP endpoint in the target computer is by using netcat:
38//!
39//! ```console
40//! $ ncat -l 12345
41//! [src/lib.rs:123] Hello!
42//! ```
43
44use dashmap::DashMap;
45use std::fmt;
46use std::fs::File;
47use std::io;
48use std::io::Write;
49use std::net::TcpStream;
50use std::str::FromStr;
51use std::sync::LazyLock;
52
53static DIRTY_FILES: LazyLock<DashMap<&str, File>> = LazyLock::new(DashMap::new);
54
55static DIRTY_TCP: LazyLock<DashMap<(&str, u16), TcpStream>> = LazyLock::new(DashMap::new);
56
57/// Writes a message to the given location.  The message will be formatted.
58///
59/// # Example — Logging to a file
60///
61/// ```rust,no_run
62/// # use dirty_debug::ddbg;
63/// #
64/// ddbg!("/tmp/log", "Hello {}!", "world");
65/// ```
66///
67/// # Example — Logging to a tcp endpoint
68///
69/// ```rust,no_run
70/// # use dirty_debug::ddbg;
71/// #
72/// ddbg!("tcp://192.168.1.42:12345", "Hello {}!", "world");
73/// ```
74#[macro_export]
75macro_rules! ddbg {
76    ($uri:expr, $f:literal) => {{
77        $crate::dirty_log_message(
78            $uri,
79            ::std::format_args!(::std::concat!("[{}:{}] ", $f), ::std::file!(), ::std::line!()),
80        );
81    }};
82    ($uri:expr, $f:literal, $($arg:tt)*) => {{
83        $crate::dirty_log_message(
84            $uri,
85            ::std::format_args!(::std::concat!("[{}:{}] ", $f), ::std::file!(), ::std::line!(), $($arg)*),
86        );
87    }};
88}
89
90#[inline(always)]
91fn dirty_log_str_writer(writer: &mut impl Write, args: fmt::Arguments<'_>) -> io::Result<()> {
92    writer.write_fmt(args)?;
93    writer.write_all("\n".as_bytes())?;
94
95    // Performance won't be great if we flush all the time, but we don't want to lose log lines if
96    // the program crashes.
97    writer.flush()
98}
99
100#[inline(always)]
101fn dirty_log_str_file(filepath: &'static str, args: fmt::Arguments<'_>) -> io::Result<()> {
102    let mut entry = DIRTY_FILES.entry(filepath).or_try_insert_with(move || {
103        let file = File::options().create(true).append(true).open(filepath)?;
104        Ok::<_, io::Error>(file)
105    })?;
106
107    // `DashMap` ensures we have exclusive access to this file, so there is no way for two threads
108    // to write to the same line.
109    let file = entry.value_mut();
110
111    dirty_log_str_writer(file, args)
112}
113
114#[inline(always)]
115fn dirty_log_str_tcp(
116    hostname: &'static str,
117    port: u16,
118    args: fmt::Arguments<'_>,
119) -> io::Result<()> {
120    let mut entry = DIRTY_TCP.entry((hostname, port)).or_try_insert_with(move || {
121        let stream = TcpStream::connect((hostname, port))?;
122        Ok::<_, io::Error>(stream)
123    })?;
124
125    // `DashMap` ensures we have exclusive access to this stream, so there is no way for two threads
126    // to write to the same line.
127    let stream = entry.value_mut();
128
129    dirty_log_str_writer(stream, args)
130}
131
132/// Logs the given message.  The `uri` is a string with a static lifetime, so that it can be stored
133/// without cloning, to avoid extra memory allocations.
134#[doc(hidden)]
135pub fn dirty_log_message(uri: &'static str, args: fmt::Arguments<'_>) {
136    let result = if let Some(authority) = uri.strip_prefix("tcp://") {
137        let (hostname, port) = authority.rsplit_once(':').expect("invalid tcp uri");
138
139        // Ensure sure we can handle IPv6 uris like `tcp://[::1]:1234`:
140        let hostname =
141            hostname.strip_prefix('[').and_then(|h| h.strip_suffix(']')).unwrap_or(hostname);
142        let port = u16::from_str(port).expect("invalid port number");
143
144        dirty_log_str_tcp(hostname, port, args)
145    } else {
146        let filepath = uri.strip_prefix("file://").unwrap_or(uri);
147
148        dirty_log_str_file(filepath, args)
149    };
150
151    if let Err(e) = result {
152        panic!("failed to log to \"{uri}\": {e}");
153    }
154}
155
156#[cfg(test)]
157mod test {
158    use indoc::indoc;
159    use std::collections::HashSet;
160    use std::io::Read;
161    use std::net::TcpStream;
162    use std::thread::JoinHandle;
163
164    struct TempFilepath {
165        filepath: String,
166    }
167
168    impl TempFilepath {
169        fn new() -> TempFilepath {
170            use rand::Rng;
171            use rand::distr::Alphanumeric;
172
173            let dir = std::env::temp_dir();
174            let filename: String =
175                rand::rng().sample_iter(&Alphanumeric).take(30).map(char::from).collect();
176
177            let filepath = dir.join(format!("dirty_debug_test_{filename}")).display().to_string();
178
179            TempFilepath { filepath }
180        }
181
182        fn read(&self) -> String {
183            std::fs::read_to_string(&self.filepath).unwrap()
184        }
185    }
186
187    impl Drop for TempFilepath {
188        fn drop(&mut self) {
189            let _result = std::fs::remove_file(&self.filepath);
190        }
191    }
192
193    struct Listener {
194        thread_handler: JoinHandle<String>,
195        port: u16,
196    }
197
198    impl Listener {
199        fn new() -> Listener {
200            Listener::new_with_bind("127.0.0.1")
201        }
202
203        fn new_with_bind(bind: &str) -> Listener {
204            use std::net::TcpListener;
205            use std::thread::spawn;
206
207            let listener: TcpListener =
208                TcpListener::bind(format!("{bind}:0")).expect("fail to bind");
209
210            let port: u16 = listener.local_addr().unwrap().port();
211
212            let thread_handler = spawn(move || {
213                let mut content: String = String::with_capacity(1024);
214                let mut stream: TcpStream = listener.incoming().next().unwrap().unwrap();
215
216                while !content.contains("==EOF==") {
217                    let mut buffer: [u8; 8] = [0; 8];
218                    let read = stream.read(&mut buffer).unwrap();
219                    let s = std::str::from_utf8(&buffer[0..read]).unwrap();
220                    content.push_str(s);
221                }
222
223                content
224            });
225
226            Listener { thread_handler, port }
227        }
228
229        fn content(self) -> String {
230            self.thread_handler.join().unwrap()
231        }
232    }
233
234    /// Creates a `&'static str` out of any string.  This is important because the uri in `ddbg!()`
235    /// needs to be a string with a static lifetime to allow it to be stored without cloning it.
236    macro_rules! make_static {
237        ($str:expr) => {{
238            static CELL: ::std::sync::OnceLock<String> = ::std::sync::OnceLock::new();
239            CELL.set($str.to_owned()).unwrap();
240            CELL.get().unwrap().as_str()
241        }};
242    }
243
244    fn read_log_strip_source_info(log: &str) -> String {
245        let mut stripped_log = String::with_capacity(log.len());
246
247        for line in log.lines() {
248            let stripped = match line.starts_with('[') {
249                true => line.split_once(' ').map_or("", |(_, s)| s),
250                false => line,
251            };
252
253            stripped_log.push_str(stripped);
254            stripped_log.push('\n');
255        }
256
257        stripped_log
258    }
259
260    fn assert_log(log: &str, expected: &str) {
261        let stripped_log = read_log_strip_source_info(log);
262
263        assert_eq!(stripped_log, expected);
264    }
265
266    #[test]
267    fn test_ddbg_file_and_line_number() {
268        let temp_file: TempFilepath = TempFilepath::new();
269        let filepath: &'static str = make_static!(temp_file.filepath);
270
271        ddbg!(filepath, "test");
272        let line = line!() - 1;
273
274        assert_eq!(temp_file.read(), format!("[{}:{line}] test\n", file!()));
275    }
276
277    #[test]
278    fn test_ddbg_simple() {
279        let temp_file: TempFilepath = TempFilepath::new();
280        let filepath: &'static str = make_static!(temp_file.filepath);
281
282        ddbg!(filepath, "numbers={:?}", [1, 2, 3]);
283
284        assert_log(&temp_file.read(), "numbers=[1, 2, 3]\n");
285    }
286
287    #[test]
288    fn test_ddbg_multiple_syntaxes() {
289        let temp_file: TempFilepath = TempFilepath::new();
290        let filepath: &'static str = make_static!(temp_file.filepath);
291
292        ddbg!(filepath, "nothing to format");
293        ddbg!(filepath, "another nothing to format",);
294        ddbg!(filepath, "");
295        ddbg!(filepath, "a {} b {}", 23, "foo");
296        ddbg!(filepath, "a {} b {}", 32, "bar",);
297
298        let expected = indoc! { r#"
299            nothing to format
300            another nothing to format
301
302            a 23 b foo
303            a 32 b bar
304            "#
305        };
306
307        assert_log(&temp_file.read(), expected);
308    }
309
310    #[test]
311    fn test_ddbg_file_append() {
312        let temp_file: TempFilepath = TempFilepath::new();
313        let filepath: &'static str = make_static!(temp_file.filepath);
314
315        std::fs::write(filepath, "[file.rs:23] first\n").unwrap();
316
317        ddbg!(filepath, "second");
318
319        let expected = indoc! { r#"
320            first
321            second
322            "#
323        };
324
325        assert_log(&temp_file.read(), expected);
326    }
327
328    #[test]
329    fn test_ddbg_multiline() {
330        let temp_file: TempFilepath = TempFilepath::new();
331        let filepath: &'static str = make_static!(temp_file.filepath);
332
333        ddbg!(filepath, "This log\nmessage\nspans multiple lines!");
334
335        let expected = indoc! { r#"
336            This log
337            message
338            spans multiple lines!
339            "#
340        };
341
342        assert_log(&temp_file.read(), expected);
343    }
344
345    #[test]
346    fn test_ddbg_uri_scheme_file() {
347        let temp_file: TempFilepath = TempFilepath::new();
348        let filepath: &'static str = make_static!(format!("file://{}", temp_file.filepath));
349
350        ddbg!(filepath, "test!");
351
352        assert_log(&temp_file.read(), "test!\n");
353    }
354
355    #[test]
356    fn test_ddbg_multithread_no_corrupted_lines() {
357        use std::str::FromStr;
358        use std::thread::{JoinHandle, spawn};
359
360        const THREAD_NUM: usize = 20;
361        const ITERATIONS: usize = 1000;
362        const REPETITIONS: usize = 1000;
363
364        let temp_file: TempFilepath = TempFilepath::new();
365        let filepath: &'static str = make_static!(temp_file.filepath);
366        let mut threads: Vec<JoinHandle<()>> = Vec::with_capacity(THREAD_NUM);
367
368        for i in 0..THREAD_NUM {
369            let thread = spawn(move || {
370                for j in 0..ITERATIONS {
371                    ddbg!(filepath, "{}", format!("{i}:{j}_").repeat(REPETITIONS));
372                }
373            });
374
375            threads.push(thread);
376        }
377
378        for thread in threads {
379            thread.join().unwrap();
380        }
381
382        let mut lines_added: HashSet<(usize, usize)> =
383            HashSet::with_capacity(THREAD_NUM * ITERATIONS);
384
385        for i in 0..THREAD_NUM {
386            for j in 0..ITERATIONS {
387                lines_added.insert((i, j));
388            }
389        }
390
391        let log = read_log_strip_source_info(&temp_file.read());
392
393        for line in log.lines() {
394            let token = line.split('_').next().unwrap();
395            let mut iter = token.split(':');
396            let i = usize::from_str(iter.next().unwrap()).unwrap();
397            let j = usize::from_str(iter.next().unwrap()).unwrap();
398            let expected = format!("{i}:{j}_").repeat(REPETITIONS);
399
400            assert_eq!(line, expected);
401
402            lines_added.remove(&(i, j));
403        }
404
405        assert!(lines_added.is_empty());
406    }
407
408    #[test]
409    fn test_ddbg_uri_scheme_tcp_hostname() {
410        let tcp_listener: Listener = Listener::new();
411        let uri: &'static str = make_static!(format!("tcp://localhost:{}", tcp_listener.port));
412
413        ddbg!(uri, "test hostname!");
414        ddbg!(uri, "==EOF==");
415
416        assert_log(&tcp_listener.content(), "test hostname!\n==EOF==\n");
417    }
418
419    #[test]
420    fn test_ddbg_uri_scheme_tcp_ipv4() {
421        let tcp_listener: Listener = Listener::new();
422        let uri: &'static str = make_static!(format!("tcp://127.0.0.1:{}", tcp_listener.port));
423
424        ddbg!(uri, "test ipv4!");
425        ddbg!(uri, "==EOF==");
426
427        assert_log(&tcp_listener.content(), "test ipv4!\n==EOF==\n");
428    }
429
430    #[test]
431    fn test_ddbg_uri_scheme_tcp_ipv6() {
432        let tcp_listener: Listener = Listener::new_with_bind("::1");
433        let uri: &'static str = make_static!(format!("tcp://[::1]:{}", tcp_listener.port));
434
435        ddbg!(uri, "test ipv6!");
436        ddbg!(uri, "==EOF==");
437
438        assert_log(&tcp_listener.content(), "test ipv6!\n==EOF==\n");
439    }
440}