odebug/
lib.rs

1#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/", "README.md"))]
2
3use once_cell::sync::Lazy;
4use std::collections::HashSet;
5use std::env;
6use std::fs::{self, OpenOptions};
7use std::io::Write;
8use std::path::PathBuf;
9
10pub static DEBUG_DIR: Lazy<PathBuf> = Lazy::new(|| {
11    let debug_dir = determine_debug_dir();
12    fs::create_dir_all(&debug_dir).unwrap_or_else(|e| {
13        eprintln!("Failed to create debug directory: {}", e);
14    });
15    debug_dir
16});
17
18/// Determines the appropriate debug directory based on feature flags
19fn determine_debug_dir() -> PathBuf {
20    #[cfg(feature = "output_to_target")]
21    {
22        find_target_dir()
23            .map(|dir| dir.join("odebug"))
24            .unwrap_or_else(|| {
25                eprintln!(
26                    "Warning: Could not find target directory, falling back to default location"
27                );
28                default_debug_dir()
29            })
30    }
31
32    #[cfg(not(feature = "output_to_target"))]
33    {
34        default_debug_dir()
35    }
36}
37
38/// Returns the default debug directory (either workspace root or current dir)/.debug
39fn default_debug_dir() -> PathBuf {
40    #[cfg(feature = "use_workspace")]
41    {
42        find_workspace_root()
43            .map(|root| root.join(".debug"))
44            .unwrap_or_else(|| {
45                eprintln!(
46                    "Warning: Could not find workspace root, falling back to current directory"
47                );
48                std::env::current_dir().unwrap_or_default().join(".debug")
49            })
50    }
51
52    #[cfg(not(feature = "use_workspace"))]
53    {
54        std::env::current_dir().unwrap_or_default().join(".debug")
55    }
56}
57
58#[cfg(feature = "output_to_target")]
59fn find_target_dir() -> Option<PathBuf> {
60    if let Ok(dir) = std::env::var("CARGO_TARGET_DIR") {
61        return Some(PathBuf::from(dir));
62    }
63
64    #[cfg(feature = "use_workspace")]
65    {
66        if let Some(ws_root) = find_workspace_root() {
67            return Some(ws_root.join("target"));
68        }
69    }
70
71    let current = std::env::current_dir().ok()?;
72    Some(current.join("target"))
73}
74
75#[cfg(feature = "use_workspace")]
76fn find_workspace_root() -> Option<PathBuf> {
77    let mut current_dir = env::current_dir().ok()?;
78    loop {
79        let cargo_toml_path = current_dir.join("Cargo.toml");
80        if cargo_toml_path.exists() {
81            if let Ok(content) = fs::read_to_string(&cargo_toml_path) {
82                if content.contains("[workspace]") {
83                    return Some(current_dir);
84                }
85            }
86        }
87        if !current_dir.pop() {
88            break;
89        }
90    }
91    None
92}
93
94#[doc(hidden)]
95const SEPARATOR_LINE: &str = "-----------------------------------------------------------";
96
97#[doc(hidden)]
98static INITIALIZED_FILES: Lazy<std::sync::Mutex<HashSet<String>>> =
99    Lazy::new(|| std::sync::Mutex::new(HashSet::new()));
100
101/// Writes content to a debug log file with optional header and context information.
102///
103/// # Parameters
104///
105/// * `filename` - Name of the log file
106/// * `content` - Content to write to the log file
107/// * `header` - Optional header to include before the content
108/// * `context` - Optional context information (typically file and line number)
109///
110/// # Returns
111///
112/// `std::io::Result<()>` indicating success or failure
113///
114/// # Examples
115///
116/// ```
117/// # use odebug::write_to_debug_file;
118/// write_to_debug_file(
119///     "debug.log",
120///     "Something happened",
121///     Some("INFO"),
122///     Some("main.rs:42")
123/// ).expect("Failed to write to log");
124/// ```
125pub fn write_to_debug_file(
126    filename: &str,
127    content: &str,
128    header: Option<&str>,
129    context: Option<&str>,
130) -> std::io::Result<()> {
131    let _ = fs::create_dir_all(&*DEBUG_DIR);
132
133    let path = DEBUG_DIR.join(filename);
134
135    let should_clear = {
136        let mut initialized = INITIALIZED_FILES.lock().unwrap();
137        if !initialized.contains(filename) {
138            initialized.insert(filename.to_string());
139            true
140        } else {
141            false
142        }
143    };
144
145    if should_clear {
146        let _ = fs::remove_file(&path);
147    }
148
149    // buffered writer for better performance
150    let file = OpenOptions::new().create(true).append(true).open(&path)?;
151    let mut writer = std::io::BufWriter::new(file);
152
153    match (header, context) {
154        (Some(header), Some(context)) => {
155            writeln!(writer, "\n{0}", SEPARATOR_LINE)?;
156            writeln!(writer, "> {0} ({1})", header, context)?;
157            writeln!(writer, "{0}", SEPARATOR_LINE)?;
158            writeln!(writer, "{0}", content)?;
159        },
160        (Some(header), None) => {
161            writeln!(writer, "\n{0}", SEPARATOR_LINE)?;
162            writeln!(writer, "> {0}", header)?;
163            writeln!(writer, "{0}", SEPARATOR_LINE)?;
164            writeln!(writer, "{0}", content)?;
165        },
166        (None, Some(context)) => {
167            writeln!(writer, "\n{0}", SEPARATOR_LINE)?;
168            writeln!(writer, "> [at {0}]", context)?;
169            writeln!(writer, "{0}", SEPARATOR_LINE)?;
170            writeln!(writer, "{0}", content)?;
171        },
172        (None, None) => {
173            writeln!(writer, "\n{0}", content)?;
174        },
175    }
176
177    writer.flush()?;
178
179    Ok(())
180}
181
182#[macro_export]
183/// Logs debug information to files with zero runtime overhead in release builds.
184///
185/// In its fundamentals, it just writes to files, but it provides a flexible syntax for specifying
186/// the file name, headers, and content. It also includes some rudimentary helpful meta data, such
187/// as the file name and line number where the macro was invoked.
188///
189/// This macro is only active in debug builds or when the `always_log` feature is enabled.
190/// In release builds with no `always_log` feature, it compiles to nothing.
191///
192/// # Examples
193///
194/// Basic logging to default file:
195/// ```
196/// use odebug::odebug;
197/// odebug!("Simple debug message");
198/// odebug!("Formatted message: {}", 42);
199/// ```
200///
201/// Custom file and headers:
202/// ```
203/// use odebug::odebug;
204/// // Log to custom file
205/// odebug!("custom.log" => "Message in custom file");
206///
207/// // Using path-like syntax with headers
208/// odebug!(custom::Header("Message with header"));
209/// ```
210///
211/// Method chaining syntax:
212/// ```
213/// use odebug::odebug;
214/// odebug!("Debug info".to_file("output.log"));
215/// odebug!("Important message".with_header("IMPORTANT"));
216/// odebug!("Error details".to_file("errors.log").with_header("ERROR"));
217/// ```
218macro_rules! odebug {
219    ($($args:tt)*) => {
220        #[cfg(any(debug_assertions, feature = "always_log"))]
221        {
222            $crate::__internal_debug_macro!($($args)*)
223        }
224    };
225}
226
227#[doc(hidden)]
228#[macro_export]
229macro_rules! __internal_debug_macro {
230    // path-like syntax with file and header
231    ($file:ident::$header:ident($content:expr)) => {{
232        let context = format!("{}:{}", file!(), line!());
233        $crate::write_to_debug_file(
234            &format!("{}.log", stringify!($file)),
235            &$content.to_string(),
236            Some(stringify!($header)),
237            Some(&context)
238        ).unwrap_or_else(|e| eprintln!("Failed to write debug log: {}", e))
239    }};
240
241    // path-like syntax with file and header, formatted content
242    ($file:ident::$header:ident($fmt:expr, $($arg:tt)+)) => {{
243        let context = format!("{}:{}", file!(), line!());
244        let content = format!($fmt, $($arg)+);
245        $crate::write_to_debug_file(
246            &format!("{}.log", stringify!($file)),
247            &content,
248            Some(stringify!($header)),
249            Some(&context)
250        ).unwrap_or_else(|e| eprintln!("Failed to write debug log: {}", e))
251    }};
252
253    // path-like syntax with just file
254    ($file:ident::($content:expr)) => {{
255        let context = format!("{}:{}", file!(), line!());
256        $crate::write_to_debug_file(
257            &format!("{}.log", stringify!($file)),
258            &$content.to_string(),
259            None,
260            Some(&context)
261        ).unwrap_or_else(|e| eprintln!("Failed to write debug log: {}", e))
262    }};
263
264    // path-like syntax with just file, formatted content
265    ($file:ident::($fmt:expr, $($arg:tt)+)) => {{
266        let context = format!("{}:{}", file!(), line!());
267        let content = format!($fmt, $($arg)+);
268        $crate::write_to_debug_file(
269            &format!("{}.log", stringify!($file)),
270            &content,
271            None,
272            Some(&context)
273        ).unwrap_or_else(|e| eprintln!("Failed to write debug log: {}", e))
274    }};
275
276    // just header syntax
277    (::$header:ident($content:expr)) => {{
278        let context = format!("{}:{}", file!(), line!());
279        $crate::write_to_debug_file(
280            "debug.log",
281            &$content.to_string(),
282            Some(stringify!($header)),
283            Some(&context)
284        ).unwrap_or_else(|e| eprintln!("Failed to write debug log: {}", e))
285    }};
286
287    // just header syntax with formatted content
288    (::$header:ident($fmt:expr, $($arg:tt)+)) => {{
289        let context = format!("{}:{}", file!(), line!());
290        let content = format!($fmt, $($arg)+);
291        $crate::write_to_debug_file(
292            "debug.log",
293            &content,
294            Some(stringify!($header)),
295            Some(&context)
296        ).unwrap_or_else(|e| eprintln!("Failed to write debug log: {}", e))
297    }};
298
299    // string literal filename support (keeping => syntax)
300    ($file:expr => $content:expr) => {{
301        let context = format!("{}:{}", file!(), line!());
302        $crate::write_to_debug_file(
303            $file,
304            &$content.to_string(),
305            None,
306            Some(&context)
307        ).unwrap_or_else(|e| eprintln!("Failed to write debug log: {}", e))
308    }};
309
310    // string literal filename with formatted content
311    ($file:expr => $fmt:expr, $($arg:tt)+) => {{
312        let context = format!("{}:{}", file!(), line!());
313        let content = format!($fmt, $($arg)*);
314        $crate::write_to_debug_file(
315            $file,
316            &content,
317            None,
318            Some(&context)
319        ).unwrap_or_else(|e| eprintln!("Failed to write debug log: {}", e))
320    }};
321
322    // method chaining for literals
323    ($content:literal.to_file($file:expr)) => {{
324        let context = format!("{}:{}", file!(), line!());
325        $crate::write_to_debug_file(
326            $file,
327            &$content.to_string(),
328            None,
329            Some(&context)
330        ).unwrap_or_else(|e| eprintln!("Failed to write debug log: {}", e))
331    }};
332
333    ($content:literal.with_header($header:expr)) => {{
334        let context = format!("{}:{}", file!(), line!());
335        $crate::write_to_debug_file(
336            "debug.log",
337            &$content.to_string(),
338            Some(&$header.to_string()),
339            Some(&context)
340        ).unwrap_or_else(|e| eprintln!("Failed to write debug log: {}", e))
341    }};
342
343    // combined method chaining for literals
344    ($content:literal.to_file($file:expr).with_header($header:expr)) => {{
345        let context = format!("{}:{}", file!(), line!());
346        $crate::write_to_debug_file(
347            $file,
348            &$content.to_string(),
349            Some(&$header.to_string()),
350            Some(&context)
351        ).unwrap_or_else(|e| eprintln!("Failed to write debug log: {}", e))
352    }};
353
354    // method chaining for identifiers
355    ($content:ident.to_file($file:expr)) => {{
356        let context = format!("{}:{}", file!(), line!());
357        $crate::write_to_debug_file(
358            $file,
359            &$content.to_string(),
360            None,
361            Some(&context)
362        ).unwrap_or_else(|e| eprintln!("Failed to write debug log: {}", e))
363    }};
364
365    ($content:ident.with_header($header:expr)) => {{
366        let context = format!("{}:{}", file!(), line!());
367        $crate::write_to_debug_file(
368            "debug.log",
369            &$content.to_string(),
370            Some(&$header.to_string()),
371            Some(&context)
372        ).unwrap_or_else(|e| eprintln!("Failed to write debug log: {}", e))
373    }};
374
375    ($content:ident.to_file($file:expr).with_header($header:expr)) => {{
376        let context = format!("{}:{}", file!(), line!());
377        $crate::write_to_debug_file(
378            $file,
379            &$content.to_string(),
380            Some(&$header.to_string()),
381            Some(&context)
382        ).unwrap_or_else(|e| eprintln!("Failed to write debug log: {}", e))
383    }};
384
385    // simple content (default file, no header)
386    ($content:expr) => {{
387        let context = format!("{}:{}", file!(), line!());
388        $crate::write_to_debug_file(
389            "debug.log",
390            &$content.to_string(),
391            None,
392            Some(&context)
393        ).unwrap_or_else(|e| eprintln!("Failed to write debug log: {}", e))
394    }};
395
396    // format string (default file, no header)
397    ($fmt:expr, $($arg:tt)+) => {{
398        let context = format!("{}:{}", file!(), line!());
399        let content = format!($fmt, $($arg)+);
400        $crate::write_to_debug_file(
401            "debug.log",
402            &content,
403            None,
404            Some(&context)
405        ).unwrap_or_else(|e| eprintln!("Failed to write debug log: {}", e))
406    }};
407}
408
409#[cfg(test)]
410mod tests {
411    use once_cell::sync::Lazy;
412    use std::fs;
413    use std::path::Path;
414    use std::sync::Mutex;
415
416    static TEST_MUTEX: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
417
418    fn cleanup_test_logs() {
419        let debug_dir = crate::DEBUG_DIR.as_path();
420        let files = ["debug.log", "custom.log", "test.log"];
421        for file in files {
422            let _ = fs::remove_file(debug_dir.join(file));
423        }
424    }
425
426    #[test]
427    fn test_default_variants() {
428        let _guard = TEST_MUTEX.lock().unwrap();
429        cleanup_test_logs();
430
431        // Test format string variant
432        odebug!("Test value: {}", 42);
433
434        // Test plain content variant
435        odebug!("Plain message");
436
437        // Test header and content variant (now using path syntax)
438        odebug!(::TestHeader("Test content"));
439
440        // Verify file was created
441        let path = crate::DEBUG_DIR.join("debug.log");
442        assert!(Path::new(&path).exists(), "debug.log should exist");
443
444        // Verify file content
445        let content = fs::read_to_string(path).unwrap();
446        let expected_values = ["Test value: 42", "Plain message", "TestHeader", "Test content"];
447
448        for expected in expected_values {
449            assert!(
450                content.contains(expected),
451                "Log should contain: '{}'",
452                expected
453            );
454        }
455    }
456
457    #[test]
458    fn test_custom_filename_variants() {
459        let _guard = TEST_MUTEX.lock().unwrap();
460        cleanup_test_logs();
461
462        // Test all custom filename variants with the new syntax
463        odebug!(custom::("Test value: {}", 42));
464        odebug!(custom::("Plain message"));
465        odebug!(custom::TestHeader("Test content"));
466        odebug!("custom.log" => "Alternative content");
467
468        // Verify file was created
469        let path = crate::DEBUG_DIR.join("custom.log");
470        assert!(Path::new(&path).exists(), "custom.log should exist");
471
472        // Verify file content
473        let content = fs::read_to_string(path).unwrap();
474        let expected_values = [
475            "Test value: 42",
476            "Plain message",
477            "TestHeader",
478            "Test content",
479            "Alternative content",
480        ];
481
482        for expected in expected_values {
483            assert!(
484                content.contains(expected),
485                "Log should contain: '{}'",
486                expected
487            );
488        }
489    }
490
491    #[test]
492    fn test_string_literal_filename_variants() {
493        let _guard = TEST_MUTEX.lock().unwrap();
494        cleanup_test_logs();
495
496        // Test string filename variants with => syntax
497        odebug!("test.log" => "Test value: {}", 42);
498        odebug!("test.log" => "Plain message");
499        odebug!("test.log" => "Test content");
500
501        // Verify file was created
502        let path = crate::DEBUG_DIR.join("test.log");
503        assert!(Path::new(&path).exists(), "test.log should exist");
504
505        // Verify file content
506        let content = fs::read_to_string(path).unwrap();
507        let expected_values = ["Test value: 42", "Plain message", "Test content"];
508
509        for expected in expected_values {
510            assert!(
511                content.contains(expected),
512                "Log should contain: '{}'",
513                expected
514            );
515        }
516    }
517
518    #[test]
519    fn test_literal_method_chaining() {
520        let _guard = TEST_MUTEX.lock().unwrap();
521        cleanup_test_logs();
522
523        // Test literal method chaining
524        odebug!("Message".to_file("chain.log"));
525        odebug!("Message".with_header("Test Header"));
526        odebug!("Message".to_file("chain.log").with_header("Combined"));
527
528        // Verify files were created
529        let debug_path = crate::DEBUG_DIR.join("debug.log");
530        let chain_path = crate::DEBUG_DIR.join("chain.log");
531
532        assert!(Path::new(&debug_path).exists(), "debug.log should exist");
533        assert!(Path::new(&chain_path).exists(), "chain.log should exist");
534
535        // Verify content
536        let debug_content = fs::read_to_string(debug_path).unwrap();
537        let chain_content = fs::read_to_string(chain_path).unwrap();
538
539        assert!(
540            debug_content.contains("Test Header"),
541            "debug.log should contain the header"
542        );
543        assert!(
544            chain_content.contains("Message"),
545            "chain.log should contain the message"
546        );
547        assert!(
548            chain_content.contains("Combined"),
549            "chain.log should contain the combined header"
550        );
551    }
552
553    #[test]
554    fn test_identifier_method_chaining() {
555        let _guard = TEST_MUTEX.lock().unwrap();
556        cleanup_test_logs();
557
558        // Create variables to test identifier chaining
559        let message = "Variable message".to_string();
560        let header = "Variable header".to_string();
561
562        // Test identifier method chaining
563        odebug!(message.to_file("var.log"));
564        odebug!(message.with_header(header));
565        odebug!(message.to_file("var.log").with_header("Combined"));
566
567        // Verify files were created
568        let debug_path = crate::DEBUG_DIR.join("debug.log");
569        let var_path = crate::DEBUG_DIR.join("var.log");
570
571        assert!(Path::new(&debug_path).exists(), "debug.log should exist");
572        assert!(Path::new(&var_path).exists(), "var.log should exist");
573
574        // Verify content
575        let debug_content = fs::read_to_string(debug_path).unwrap();
576        let var_content = fs::read_to_string(var_path).unwrap();
577
578        assert!(
579            debug_content.contains("Variable header"),
580            "debug.log should contain the variable header"
581        );
582        assert!(
583            var_content.contains("Variable message"),
584            "var.log should contain the variable message"
585        );
586        assert!(
587            var_content.contains("Combined"),
588            "var.log should contain the combined header"
589        );
590    }
591}
592
593#[cfg(test)]
594mod feature_tests {
595    use super::*;
596    use std::sync::Mutex;
597
598    static ENV_TEST_MUTEX: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
599
600    fn get_debug_dir_path() -> PathBuf {
601        determine_debug_dir()
602    }
603
604    #[test]
605    fn test_debug_dir_location() {
606        let _guard = ENV_TEST_MUTEX.lock().unwrap();
607        let dir = get_debug_dir_path();
608        println!("Debug directory would be: {}", dir.display());
609
610        #[cfg(feature = "output_to_target")]
611        {
612            // We specifically want to test the default behavior (without env vars)
613            assert!(
614                dir.to_string_lossy().contains("target/odebug")
615                    || dir.to_string_lossy().contains("target\\odebug"),
616                "Default path should contain 'target/odebug'"
617            );
618
619            assert!(
620                dir.file_name().map_or(false, |name| name == "odebug"),
621                "Path should end with 'odebug' directory"
622            );
623        }
624
625        // Keep the rest of your existing test cases for other features
626        #[cfg(all(not(feature = "output_to_target"), feature = "use_workspace"))]
627        {
628            assert!(
629                dir.ends_with(".debug"),
630                "With use_workspace enabled, path should end with '.debug'"
631            );
632        }
633
634        #[cfg(all(not(feature = "output_to_target"), not(feature = "use_workspace")))]
635        {
636            let expected = std::env::current_dir().unwrap_or_default().join(".debug");
637            assert_eq!(dir, expected, "Default path should be current_dir/.debug");
638        }
639    }
640
641    // Test with environment variable
642    #[test]
643    #[cfg(feature = "output_to_target")]
644    fn test_target_dir_env_var() {
645        let _guard = ENV_TEST_MUTEX.lock().unwrap();
646        let test_dir = "/tmp/test_target_dir";
647        std::env::set_var("CARGO_TARGET_DIR", test_dir);
648
649        let dir = get_debug_dir_path();
650
651        assert!(
652            dir.starts_with(test_dir),
653            "Should use CARGO_TARGET_DIR when set"
654        );
655
656        std::env::remove_var("CARGO_TARGET_DIR");
657    }
658}