Skip to main content

tale_ndjson/
config.rs

1//! A module to corral configuration to the side and into a single
2//! source of truth. Provides conveniences for answering config
3//! questions without having to pass batons around.
4
5use std::path::PathBuf;
6
7/// A sensible holder for our configuration.
8#[derive(Debug, Clone, Default)]
9pub struct ConfigOpts {
10    pub tailing: bool,
11    pub sticky: bool,
12    pub offset: i64,
13    pub offset_unit: OffsetUnit,
14    pub show_time: bool,
15    pub batch_window_ms: u64,
16    pub mode: InputMode,
17    pub force_chunked: bool,
18    pub disable_chunked: bool,
19    pub conservative: bool,
20    pub no_file_names: bool,
21    pub all_file_names: bool,
22    pub adaptive: bool,
23    pub strategy: Option<Strategy>,
24    pub max_memory: Option<usize>,
25    #[cfg(debug_assertions)]
26    pub profile_json: bool,
27}
28
29#[derive(Debug, Clone, Default, Copy)]
30pub enum OffsetUnit {
31    #[default]
32    Lines,
33    Blocks,
34    Bytes,
35}
36
37/// Operation modes for tale
38#[derive(Debug, Clone, Default)]
39pub enum InputMode {
40    /// Read from stdin
41    #[default]
42    Stdin,
43    /// Read from a single file
44    SingleFile { path: PathBuf },
45    /// Watch multiple files
46    MultiFile { paths: Vec<PathBuf> },
47}
48
49// Production implementation: Simple OnceLock for fast access
50#[cfg(not(test))]
51mod runtime {
52    use std::sync::OnceLock;
53
54    use super::ConfigOpts;
55
56    /// Hold our configuration - production uses simple OnceLock
57    pub static CONFIG: OnceLock<ConfigOpts> = OnceLock::new();
58
59    /// Get reference to configuration
60    pub fn config() -> &'static ConfigOpts {
61        CONFIG
62            .get()
63            .expect("programmer error: tried to access configuration before it was set")
64    }
65
66    /// Set configuration - one-time initialization only
67    pub fn set(input: ConfigOpts) -> Result<(), Box<ConfigOpts>> {
68        match CONFIG.set(input) {
69            Ok(_) => Ok(()),
70            Err(e) => Err(Box::new(e)),
71        }
72    }
73}
74
75// Test implementation: Thread-local storage for isolation
76#[cfg(test)]
77mod runtime {
78    use std::cell::RefCell;
79
80    use super::ConfigOpts;
81
82    // Test implementation uses thread-local storage for isolation
83    thread_local! {
84        pub static TEST_CONFIG: RefCell<Option<ConfigOpts>> = const { RefCell::new(None) };
85    }
86
87    /// Get configuration - returns owned value from thread-local storage
88    pub fn config() -> ConfigOpts {
89        TEST_CONFIG.with(|cfg| cfg.borrow().as_ref().cloned().unwrap_or_else(ConfigOpts::default))
90    }
91
92    /// Set configuration - can be called multiple times per thread
93    pub fn set(input: ConfigOpts) -> Result<(), Box<ConfigOpts>> {
94        TEST_CONFIG.with(|cfg| {
95            *cfg.borrow_mut() = Some(input);
96        });
97        Ok(())
98    }
99
100    /// Test-only helper to modify config in place
101    pub fn update<F>(f: F)
102    where
103        F: FnOnce(&mut ConfigOpts),
104    {
105        TEST_CONFIG.with(|cfg| {
106            let mut borrowed = cfg.borrow_mut();
107            if borrowed.is_none() {
108                *borrowed = Some(ConfigOpts::default());
109            }
110            if let Some(ref mut config) = borrowed.as_mut() {
111                f(config);
112            }
113        });
114    }
115
116    /// Test-only helper for temporary config changes
117    pub fn with_config<F, R>(new_config: ConfigOpts, f: F) -> R
118    where
119        F: FnOnce() -> R,
120    {
121        // Save old config
122        let old_config = TEST_CONFIG.with(|cfg| cfg.borrow().clone());
123
124        // Set new config
125        TEST_CONFIG.with(|cfg| {
126            *cfg.borrow_mut() = Some(new_config);
127        });
128
129        // Run the test
130        let result = f();
131
132        // Restore old config
133        TEST_CONFIG.with(|cfg| {
134            *cfg.borrow_mut() = old_config;
135        });
136
137        result
138    }
139}
140
141// Re-export the runtime implementation as the public API
142use miette::Result;
143pub use runtime::{config, set};
144#[cfg(test)]
145pub use runtime::{update, with_config};
146
147use crate::defaults::{SystemDefaults, get_system_config};
148use crate::errors::TaleError;
149use crate::readers::{AdaptiveStrategy, ConservativeStrategy, StaticStrategy, Strategy};
150
151// Public convenience accessors - these work with both implementations
152pub fn tailing() -> bool {
153    #[cfg(not(test))]
154    return config().tailing;
155    #[cfg(test)]
156    return config().tailing;
157}
158
159pub fn sticky() -> bool {
160    #[cfg(not(test))]
161    return config().sticky;
162    #[cfg(test)]
163    return config().sticky;
164}
165
166pub fn offset() -> i64 {
167    #[cfg(not(test))]
168    return config().offset;
169    #[cfg(test)]
170    return config().offset;
171}
172
173pub fn offset_unit() -> OffsetUnit {
174    #[cfg(not(test))]
175    return config().offset_unit;
176    #[cfg(test)]
177    return config().offset_unit;
178}
179
180pub fn show_time() -> bool {
181    #[cfg(not(test))]
182    return config().show_time;
183    #[cfg(test)]
184    return config().show_time;
185}
186
187pub fn batch_window_ms() -> u64 {
188    #[cfg(not(test))]
189    return config().batch_window_ms;
190    #[cfg(test)]
191    return config().batch_window_ms;
192}
193
194pub fn force_chunked() -> bool {
195    #[cfg(not(test))]
196    return config().force_chunked;
197    #[cfg(test)]
198    return config().force_chunked;
199}
200
201pub fn disable_chunked() -> bool {
202    #[cfg(not(test))]
203    return config().disable_chunked;
204    #[cfg(test)]
205    return config().disable_chunked;
206}
207
208pub fn conservative() -> bool {
209    #[cfg(not(test))]
210    return config().conservative;
211    #[cfg(test)]
212    return config().conservative;
213}
214
215pub fn mode() -> InputMode {
216    #[cfg(not(test))]
217    return config().mode.clone();
218    #[cfg(test)]
219    return config().mode;
220}
221
222/// Unescape shell-escaped glob patterns (e.g., \* -> *, \? -> ?, \[ -> [)
223fn unescape_glob_pattern(pattern: &str) -> String {
224    let mut result = String::new();
225    let mut chars = pattern.chars().peekable();
226
227    while let Some(ch) = chars.next() {
228        if ch == '\\' {
229            // Check if next character is a glob metacharacter
230            if let Some(&next_ch) = chars.peek() {
231                if matches!(next_ch, '*' | '?' | '[' | ']' | '{' | '}') {
232                    // Skip the backslash and add the escaped character
233                    chars.next();
234                    result.push(next_ch);
235                } else {
236                    // Not escaping a glob metacharacter, keep the backslash
237                    result.push(ch);
238                }
239            } else {
240                // Backslash at end of string
241                result.push(ch);
242            }
243        } else {
244            result.push(ch);
245        }
246    }
247
248    result
249}
250
251/// Check if a string contains glob patterns, including shell-escaped ones
252fn is_glob(maybe: &str) -> bool {
253    // Check for unescaped glob patterns, for users who turn on noglob.
254    if maybe.contains('?') || maybe.contains('*') || maybe.contains('[') || maybe.contains('{') {
255        return true;
256    }
257
258    // Check for shell-escaped glob patterns
259    maybe.contains("\\*") || maybe.contains("\\?") || maybe.contains("\\[") || maybe.contains("\\{")
260}
261
262/// Amongst our list of files to tail we might have a glob pattern
263/// to expand. If so, we find matches. Otherwise, we add that path
264/// to our list directly.
265fn expand_globs(args: &[String]) -> Result<Vec<PathBuf>, TaleError> {
266    let mut all_paths = Vec::new();
267
268    for candidate in args {
269        if is_glob(candidate.as_str()) {
270            // Unescape shell-escaped glob patterns before expansion
271            let unescaped_pattern = unescape_glob_pattern(candidate);
272            let pattern = glob::glob(&unescaped_pattern)?;
273            for fpath in pattern.flatten() {
274                if fpath.is_file() {
275                    all_paths.push(fpath);
276                }
277            }
278        } else {
279            let fpath = PathBuf::from(candidate);
280            if fpath.exists() && fpath.is_file() {
281                all_paths.push(fpath);
282            }
283        }
284    }
285    all_paths.sort();
286    Ok(all_paths)
287}
288
289fn handle_possible_paths(args: &[String]) -> Result<Vec<PathBuf>, TaleError> {
290    match expand_globs(args) {
291        Ok(paths) => {
292            if paths.is_empty() {
293                // No files matched the glob pattern(s)
294                let patterns: Vec<String> = args.iter().map(|s| format!("'{}'", s)).collect();
295                Err(TaleError::from(Box::new(crate::errors::FileError::NotFound {
296                    path: PathBuf::from(patterns.join(", ")),
297                    similar_files: vec![
298                        "Check if the glob pattern is correct".to_string(),
299                        "Verify the files exist in the specified directory".to_string(),
300                        "Try using an absolute path".to_string(),
301                    ],
302                })))
303            } else {
304                Ok(paths)
305            }
306        }
307        Err(e) => {
308            // Glob expansion failed - could be invalid pattern or I/O error
309            Err(e)
310        }
311    }
312}
313
314impl ConfigOpts {
315    pub fn new(args: &crate::Args) -> Result<Self> {
316        // Get production defaults
317        let system_config = get_system_config();
318        let (mode, maybe_offset) = match args.args.len() {
319            0 => (InputMode::Stdin, None),
320            1 => {
321                let only = &args.args[0];
322                if (only.starts_with('-') || only.starts_with('+'))
323                    && only.len() > 1
324                    && let Ok(offset) = only.parse::<i64>()
325                {
326                    // It's a numeric offset like "-4" or "+4"
327                    (InputMode::Stdin, Some(offset))
328                } else {
329                    // It's a filename or a glob
330                    if is_glob(only) {
331                        // It's a glob pattern, handle as multi-file
332                        let paths = handle_possible_paths(vec![only.clone()].as_slice())?;
333                        (InputMode::MultiFile { paths }, None)
334                    } else {
335                        // It's a single filename (may or may not exist) - always treat as SingleFile
336                        (
337                            InputMode::SingleFile {
338                                path: PathBuf::from(only),
339                            },
340                            None,
341                        )
342                    }
343                }
344            }
345            2 => {
346                let (first, second) = (&args.args[0], &args.args[1]);
347
348                // Check if first arg is an offset
349                if let Ok(offset) = first.parse::<i64>() {
350                    // offset + single file
351                    (
352                        InputMode::SingleFile {
353                            path: PathBuf::from(second),
354                        },
355                        Some(offset),
356                    )
357                } else {
358                    // Two file paths or globs: we're multifile for sure.
359                    let paths = handle_possible_paths(args.args.as_slice())?;
360                    (InputMode::MultiFile { paths }, None)
361                }
362            }
363            _ => {
364                // More than two paths and/or globs.
365                // We still want to know if the first arg is an offset.
366                let paths = handle_possible_paths(args.args.as_slice())?;
367                (InputMode::MultiFile { paths }, None)
368            }
369        };
370
371        let (offset, offset_unit) = if let Some(blocks) = args.blocks {
372            (blocks, OffsetUnit::Blocks)
373        } else if let Some(bytes) = args.bytes {
374            (bytes, OffsetUnit::Bytes)
375        } else if let Some(lines) = args.offset {
376            (lines, OffsetUnit::Lines)
377        } else if let Some(offset) = maybe_offset {
378            (offset, OffsetUnit::Lines)
379        } else {
380            (0, OffsetUnit::Lines)
381        };
382
383        // Apply production defaults
384        let max_memory = args.max_memory.unwrap_or_else(|| {
385            // Use production default memory budget
386            let system_percentage = system_config.memory_percentage;
387            if let Some(memory_stats) = memory_stats::memory_stats() {
388                let system_memory = memory_stats.physical_mem;
389                let calculated = (system_memory as f64 * system_percentage / 100.0) as usize;
390                calculated.clamp(SystemDefaults::MIN_MEMORY_BUDGET, SystemDefaults::MAX_MEMORY_BUDGET)
391            } else {
392                // Fallback to reasonable default
393                system_config.max_memory_mb * 1024 * 1024
394            }
395        });
396
397        #[cfg(debug_assertions)]
398        let stratarg = args.chunk_strategy.clone();
399        #[cfg(not(debug_assertions))]
400        let stratarg = None;
401
402        // Use specified strategy or production default
403        let strategy = stratarg.or_else(|| match system_config.strategy {
404            "static" => Some(Strategy::Static(StaticStrategy::default())),
405            "adaptive" => Some(Strategy::Adaptive(AdaptiveStrategy::default())),
406            "conservative" => Some(Strategy::Conservative(ConservativeStrategy::default())),
407            _ => Some(Strategy::Conservative(ConservativeStrategy::default())),
408        });
409
410        // Determine chunking behavior based on production defaults if not specified
411        let force_chunked = if args.chunked {
412            true
413        } else if args.no_chunked {
414            false
415        } else {
416            // Use production default based on preset
417            system_config.force_chunked
418        };
419
420        Ok(Self {
421            tailing: args.follow || args.sticky,
422            sticky: args.sticky,
423            offset,
424            offset_unit,
425            show_time: args.timestamps,
426            batch_window_ms: args.window,
427            mode,
428            force_chunked,
429            disable_chunked: args.no_chunked,
430            no_file_names: args.quiet,
431            all_file_names: args.verbose,
432            adaptive: args.adaptive,
433            strategy,
434            max_memory: Some(max_memory),
435            #[cfg(debug_assertions)]
436            conservative: args.conservative,
437            #[cfg(not(debug_assertions))]
438            conservative: false,
439            #[cfg(debug_assertions)]
440            profile_json: args.profile_json,
441        })
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    #[test]
450    fn complicated_args() {
451        let args = crate::Args {
452            timestamps: true,
453            follow: true,
454            sticky: false,
455            blocks: None,
456            bytes: Some(-5),
457            offset: None,
458            verbose: false,
459            quiet: false,
460            window: 250,
461            chunked: false,
462            no_chunked: false,
463            args: vec!["-4".to_string()],
464            adaptive: false,
465            chunk_strategy: None,
466            max_memory: Some(10_000_000_000),
467            conservative: false,
468            #[cfg(debug_assertions)]
469            profile_json: false,
470        };
471        let config = ConfigOpts::new(&args).expect("Config should be valid for test");
472        assert_eq!(config.offset, -5);
473        assert!(matches!(config.mode, InputMode::Stdin));
474    }
475
476    #[test]
477    fn glob_expansions() {
478        let fixture_glob = "./fixtures/*.log".to_string();
479        let results = expand_globs(&[fixture_glob]).expect("this list of paths should expand successfully");
480        assert_eq!(results.len(), 8); // changes if we add fixtures to that directory
481        assert_eq!(
482            results.as_slice(),
483            vec![
484                PathBuf::from("fixtures/ascii_colors.log"),
485                PathBuf::from("fixtures/garbage_prefix.log"),
486                PathBuf::from("fixtures/java_stacktrace.log"),
487                PathBuf::from("fixtures/just_loglines.log"),
488                PathBuf::from("fixtures/log4j.log"),
489                PathBuf::from("fixtures/mixed_json_types.log"),
490                PathBuf::from("fixtures/mixed_text_json.log"),
491                PathBuf::from("fixtures/windows_line_endings.log")
492            ]
493        );
494    }
495
496    #[test]
497    fn can_unescape_glob_pattern() {
498        // Test basic unescaping
499        assert_eq!(unescape_glob_pattern("\\*.log"), "*.log");
500        assert_eq!(unescape_glob_pattern("test\\?.txt"), "test?.txt");
501        assert_eq!(unescape_glob_pattern("\\[abc\\]"), "[abc]");
502
503        // Test mixed patterns
504        assert_eq!(unescape_glob_pattern("\\*.log\\?"), "*.log?");
505        assert_eq!(unescape_glob_pattern("test\\*file\\?.log"), "test*file?.log");
506
507        // Test non-glob backslashes (should be preserved)
508        assert_eq!(unescape_glob_pattern("file\\name.txt"), "file\\name.txt");
509        assert_eq!(unescape_glob_pattern("path\\to\\file"), "path\\to\\file");
510
511        // Test already unescaped patterns (should be unchanged)
512        assert_eq!(unescape_glob_pattern("*.log"), "*.log");
513        assert_eq!(unescape_glob_pattern("test?.txt"), "test?.txt");
514
515        // Test empty and edge cases
516        assert_eq!(unescape_glob_pattern(""), "");
517        assert_eq!(unescape_glob_pattern("\\"), "\\");
518        assert_eq!(unescape_glob_pattern("file\\"), "file\\");
519    }
520
521    #[test]
522    fn is_glob_with_escaped_patterns_works() {
523        // Test escaped glob patterns
524        assert!(is_glob("\\*.log"));
525        assert!(is_glob("test\\?.txt"));
526        assert!(is_glob("\\[abc]"));
527
528        // Test unescaped glob patterns (existing functionality)
529        assert!(is_glob("*.log"));
530        assert!(is_glob("test?.txt"));
531        assert!(is_glob("[abc]"));
532
533        // Test non-glob patterns
534        assert!(!is_glob("file.log"));
535        assert!(!is_glob("test.txt"));
536        assert!(!is_glob("path/to/file"));
537
538        // Test backslashes that don't escape glob chars
539        assert!(!is_glob("file\\name.txt"));
540        assert!(!is_glob("path\\to\\file"));
541    }
542
543    #[test]
544    fn can_expand_escaped_globs() {
545        // This test requires the fixtures directory to exist
546        // Test that escaped glob patterns work the same as unescaped ones
547        let escaped_fixture_glob = ".\\*/fixtures/\\*.log".to_string();
548        let normal_fixture_glob = "./fixtures/*.log".to_string();
549
550        // Both should expand to the same files (if fixtures exist)
551        if let (Ok(escaped_results), Ok(normal_results)) = (
552            expand_globs(&[escaped_fixture_glob]),
553            expand_globs(&[normal_fixture_glob]),
554        ) {
555            assert_eq!(escaped_results, normal_results);
556        }
557    }
558
559    #[test]
560    fn can_modify_config() {
561        let initial_config = ConfigOpts {
562            tailing: false,
563            sticky: false,
564            offset: 10,
565            offset_unit: OffsetUnit::Lines,
566            show_time: false,
567            batch_window_ms: 250,
568            mode: InputMode::Stdin,
569            force_chunked: false,
570            disable_chunked: false,
571            ..Default::default()
572        };
573
574        // Use with_config to isolate this test
575        with_config(initial_config.clone(), || {
576            // Test the update function
577            update(|cfg| {
578                cfg.tailing = true;
579                cfg.offset = 20;
580                cfg.show_time = true;
581            });
582
583            // Verify the changes
584            assert!(tailing());
585            assert_eq!(offset(), 20);
586            assert!(show_time());
587        });
588    }
589
590    #[test]
591    fn can_test_with_config() {
592        let original_config = ConfigOpts::default();
593        set(original_config.clone()).expect("should set config");
594
595        let original_offset = offset();
596        let original_tailing = tailing();
597
598        // Test with temporary config
599        let result = with_config(
600            ConfigOpts {
601                tailing: true,
602                sticky: false,
603                offset: 42,
604                offset_unit: OffsetUnit::Bytes,
605                show_time: true,
606                batch_window_ms: 500,
607                mode: InputMode::Stdin,
608                force_chunked: true,
609                disable_chunked: false,
610                ..Default::default()
611            },
612            || {
613                // Inside this closure, config should be changed
614                assert_eq!(offset(), 42);
615                assert!(tailing());
616                assert_eq!(batch_window_ms(), 500);
617                assert!(force_chunked());
618
619                // Return a value to verify the closure ran
620                "test_successful"
621            },
622        );
623
624        // After the closure, config should be restored
625        assert_eq!(offset(), original_offset);
626        assert_eq!(tailing(), original_tailing);
627        assert_eq!(result, "test_successful");
628    }
629
630    #[test]
631    fn concurrent_access_to_test_config() {
632        use std::thread;
633        use std::time::Duration;
634
635        // Set different configs in different threads to verify isolation
636        let handles: Vec<_> = (0..3)
637            .map(|i| {
638                thread::spawn(move || {
639                    let config = ConfigOpts {
640                        offset: i * 10,
641                        tailing: i % 2 == 0,
642                        show_time: i % 2 == 1,
643                        ..ConfigOpts::default()
644                    };
645
646                    set(config).expect("should set config");
647
648                    // Sleep a bit to let other threads potentially interfere
649                    thread::sleep(Duration::from_millis(10));
650
651                    // Verify our config is still correct
652                    assert_eq!(offset(), i * 10);
653                    assert_eq!(tailing(), i % 2 == 0);
654                    assert_eq!(show_time(), i % 2 == 1);
655
656                    i
657                })
658            })
659            .collect();
660
661        // Collect results
662        let results: Vec<_> = handles
663            .into_iter()
664            .map(|h| h.join().expect("test results should always be ok"))
665            .collect();
666        assert_eq!(results, vec![0, 1, 2]);
667    }
668
669    #[test]
670    fn config_accessors_work() {
671        let test_config = ConfigOpts {
672            tailing: true,
673            sticky: true,
674            offset: -100,
675            offset_unit: OffsetUnit::Blocks,
676            show_time: true,
677            batch_window_ms: 1000,
678            mode: InputMode::SingleFile {
679                path: PathBuf::from("test.log"),
680            },
681            force_chunked: true,
682            disable_chunked: false,
683            ..Default::default()
684        };
685
686        // Use with_config to isolate this test
687        with_config(test_config, || {
688            // Test all accessor functions
689            assert!(tailing());
690            assert!(sticky());
691            assert_eq!(offset(), -100);
692            assert!(matches!(offset_unit(), OffsetUnit::Blocks));
693            assert!(show_time());
694            assert_eq!(batch_window_ms(), 1000);
695            assert!(matches!(mode(), InputMode::SingleFile { .. }));
696            assert!(force_chunked());
697            assert!(!disable_chunked());
698        });
699    }
700}