dirty_debug/
lib.rs

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