Skip to main content

toolkit/bootstrap/host/
logging.rs

1use super::super::config::{ConsoleFormat, LoggingConfig, Section};
2use anyhow::Context;
3use std::io::Write;
4use std::path::Path;
5use std::sync::{Arc, OnceLock};
6
7use parking_lot::Mutex;
8use tracing_subscriber::{Layer, fmt};
9
10// ========== OTEL-agnostic layer type (compiles with/without the feature) ==========
11#[cfg(feature = "otel")]
12pub type OtelLayer = tracing_opentelemetry::OpenTelemetryLayer<
13    tracing_subscriber::Registry,
14    opentelemetry_sdk::trace::Tracer,
15>;
16#[cfg(not(feature = "otel"))]
17pub type OtelLayer = ();
18
19// ================= level helpers =================
20
21/// Returns true if target == `crate_name` or target starts with "`crate_name::`"
22fn matches_crate_prefix(target: &str, crate_name: &str) -> bool {
23    target == crate_name
24        || (target.starts_with(crate_name) && target[crate_name.len()..].starts_with("::"))
25}
26
27// ================= rotating writer for files =================
28
29use file_rotate::{
30    ContentLimit, FileRotate,
31    compression::Compression,
32    suffix::{AppendTimestamp, FileLimit},
33};
34
35#[derive(Clone)]
36struct RotWriter(Arc<Mutex<FileRotate<AppendTimestamp>>>);
37
38impl<'a> fmt::MakeWriter<'a> for RotWriter {
39    type Writer = RotWriterHandle;
40    fn make_writer(&'a self) -> Self::Writer {
41        RotWriterHandle(self.0.clone())
42    }
43}
44
45#[derive(Clone)]
46struct RotWriterHandle(Arc<Mutex<FileRotate<AppendTimestamp>>>);
47
48impl Write for RotWriterHandle {
49    // NOTE: Each call acquires/releases the lock independently. Callers needing
50    // atomicity for multi-part writes should use write_all() or write_fmt().
51    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
52        self.0.lock().write(buf)
53    }
54    fn flush(&mut self) -> std::io::Result<()> {
55        self.0.lock().flush()
56    }
57    fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()> {
58        self.0.lock().write_all(buf)
59    }
60    fn write_fmt(&mut self, args: std::fmt::Arguments<'_>) -> std::io::Result<()> {
61        self.0.lock().write_fmt(args)
62    }
63}
64
65// A writer handle that may be None (drops writes)
66#[derive(Clone)]
67struct RoutedWriterHandle(Option<RotWriterHandle>);
68
69impl Write for RoutedWriterHandle {
70    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
71        if let Some(w) = &mut self.0 {
72            w.write(buf)
73        } else {
74            Ok(buf.len())
75        }
76    }
77    fn flush(&mut self) -> std::io::Result<()> {
78        if let Some(w) = &mut self.0 {
79            w.flush()
80        } else {
81            Ok(())
82        }
83    }
84    fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()> {
85        if let Some(w) = &mut self.0 {
86            w.write_all(buf)
87        } else {
88            Ok(())
89        }
90    }
91    fn write_fmt(&mut self, args: std::fmt::Arguments<'_>) -> std::io::Result<()> {
92        if let Some(w) = &mut self.0 {
93            w.write_fmt(args)
94        } else {
95            Ok(())
96        }
97    }
98}
99
100/// Route log records to different files by target prefix:
101/// keys are *full* prefixes like "`cf-gears::api_gateway`"
102#[derive(Clone)]
103struct MultiFileRouter {
104    default: Option<RotWriter>, // default file (from "default" section), optional
105    by_prefix: Vec<(String, RotWriter)>, // subsystem → writer, sorted longest-prefix-first
106}
107
108impl MultiFileRouter {
109    fn resolve_for(&self, target: &str) -> Option<RotWriterHandle> {
110        for (crate_name, wr) in &self.by_prefix {
111            if matches_crate_prefix(target, crate_name) {
112                return Some(RotWriterHandle(wr.0.clone()));
113            }
114        }
115        self.default.as_ref().map(|w| RotWriterHandle(w.0.clone()))
116    }
117
118    fn is_empty(&self) -> bool {
119        self.default.is_none() && self.by_prefix.is_empty()
120    }
121}
122
123impl<'a> fmt::MakeWriter<'a> for MultiFileRouter {
124    type Writer = RoutedWriterHandle;
125
126    fn make_writer(&'a self) -> Self::Writer {
127        RoutedWriterHandle(self.default.as_ref().map(|w| RotWriterHandle(w.0.clone())))
128    }
129
130    fn make_writer_for(&'a self, meta: &tracing::Metadata<'_>) -> Self::Writer {
131        let target = meta.target();
132        RoutedWriterHandle(self.resolve_for(target))
133    }
134}
135
136// ================= config extraction =================
137
138struct ConfigData<'a> {
139    default_section: Option<&'a Section>,
140    crate_sections: Vec<(String, &'a Section)>,
141}
142
143fn extract_config_data(cfg: &LoggingConfig) -> ConfigData<'_> {
144    let crate_sections = cfg
145        .iter()
146        .filter(|(k, _)| k.as_str() != "default")
147        .map(|(k, v)| (k.clone(), v))
148        .collect::<Vec<_>>();
149
150    ConfigData {
151        default_section: cfg.get("default"),
152        crate_sections,
153    }
154}
155
156// ================= path helpers =================
157
158fn create_rotating_writer_at_path(
159    log_path: &Path,
160    max_bytes: usize,
161    max_age_days: Option<u32>,
162    max_backups: Option<usize>,
163) -> Result<RotWriter, Box<dyn std::error::Error + Send + Sync>> {
164    if let Some(parent) = log_path.parent() {
165        std::fs::create_dir_all(parent)?;
166    }
167
168    // Respect retention policy: prefer MaxFiles if provided, else Age
169    let max_age_days = max_age_days.unwrap_or(1);
170    let age = chrono::Duration::try_days(i64::from(max_age_days))
171        .with_context(|| format!("Invalid max_age_days: {max_age_days}"))?;
172    let limit = if let Some(n) = max_backups {
173        FileLimit::MaxFiles(n)
174    } else {
175        FileLimit::Age(age)
176    };
177
178    let rot = FileRotate::new(
179        log_path,
180        AppendTimestamp::default(limit),
181        ContentLimit::BytesSurpassed(max_bytes),
182        Compression::None,
183        None,
184    );
185
186    Ok(RotWriter(Arc::new(Mutex::new(rot))))
187}
188
189// ================= public init (drop-in API kept) =================
190
191// Stores the `WorkerGuard` for the non-blocking console writer so it is
192// never dropped while the process is alive.  Dropping the guard shuts down
193// the background flush thread and silently loses buffered log output.
194static CONSOLE_GUARD: OnceLock<WorkerGuard> = OnceLock::new();
195
196/// Unified initializer used by both functions above.
197#[allow(unknown_lints, de1301_no_print_macros)] // runs before tracing subscriber is installed
198pub fn init_logging_unified(cfg: &LoggingConfig, base_dir: &Path, otel_layer: Option<OtelLayer>) {
199    CONSOLE_GUARD.get_or_init(|| {
200        // Bridge `log` → `tracing` *before* installing the subscriber
201        if let Err(e) = tracing_log::LogTracer::init() {
202            eprintln!("LogTracer init skipped: {e}");
203        }
204
205        let data = extract_config_data(cfg);
206
207        if data.crate_sections.is_empty() && data.default_section.is_none() {
208            // Minimal fallback (INFO to console; honors RUST_LOG)
209            return init_minimal(otel_layer);
210        }
211
212        // Build targets once, using a generic builder for different sinks
213        let file_router = build_file_router(&data, base_dir);
214
215        let console_targets = build_target_console(&data);
216        let file_targets = build_target_file(&data, file_router.default.is_some());
217
218        let console_format = data
219            .default_section
220            .map(|s| s.console_format)
221            .unwrap_or_default();
222
223        install_subscriber(
224            &console_targets,
225            &file_targets,
226            file_router,
227            console_format,
228            otel_layer,
229        )
230    });
231}
232
233// ================= generic targets builder =================
234
235use tracing::level_filters::LevelFilter;
236use tracing_appender::non_blocking::WorkerGuard;
237use tracing_subscriber::filter::Targets;
238
239/// Noisy crates that should be filtered to WARN level to avoid debug spam
240const NOISY_CRATES: &[&str] = &["h2"];
241
242fn build_target_console(config: &ConfigData) -> Targets {
243    // default level
244    let default_level = config
245        .default_section
246        .and_then(|s| s.console_level)
247        .map_or(LevelFilter::INFO, LevelFilter::from_level);
248
249    // start with default
250    let mut targets = Targets::new().with_default(default_level);
251
252    // Suppress noisy low-level crates to WARN unless they need DEBUG/TRACE
253    for crate_name in NOISY_CRATES {
254        targets = targets.with_target(*crate_name, LevelFilter::WARN);
255    }
256
257    // per-crate rules (console sink is always "active")
258    for (crate_name, section) in &config.crate_sections {
259        if let Some(level) = section.console_level.map(LevelFilter::from_level) {
260            targets = targets.with_target(crate_name.clone(), level);
261        }
262    }
263
264    targets
265}
266
267fn build_target_file(config: &ConfigData, has_default_file: bool) -> Targets {
268    // default level depends on whether there is a default file sink
269    let default_level = if has_default_file {
270        config
271            .default_section
272            .and_then(Section::file_level)
273            .map_or(LevelFilter::INFO, LevelFilter::from_level)
274    } else {
275        LevelFilter::OFF
276    };
277
278    let mut targets = Targets::new().with_default(default_level);
279
280    // per-crate rules: file sink is "active" only when path is present
281    for (crate_name, section) in &config.crate_sections {
282        if let Some(level) = section.file_level().map(LevelFilter::from_level) {
283            targets = targets.with_target(crate_name.clone(), level);
284        }
285    }
286
287    targets
288}
289
290// ================= building routers =================
291
292fn build_file_router(config: &ConfigData, base_dir: &Path) -> MultiFileRouter {
293    let mut router = MultiFileRouter {
294        default: None,
295        by_prefix: Vec::with_capacity(config.crate_sections.len()),
296    };
297
298    if let Some(section) = config.default_section {
299        router.default = create_file_writer(None, section, base_dir);
300    }
301
302    for (crate_name, section) in &config.crate_sections {
303        if let Some(writer) = create_file_writer(Some(crate_name), section, base_dir) {
304            router.by_prefix.push((crate_name.clone(), writer));
305        }
306    }
307
308    // Sort by descending prefix length so longest (most specific) match wins.
309    // Ties broken lexicographically for determinism.
310    router
311        .by_prefix
312        .sort_by(|a, b| b.0.len().cmp(&a.0.len()).then_with(|| a.0.cmp(&b.0)));
313
314    router
315}
316
317trait HasMaxSizeBytes {
318    fn max_size_bytes(&self) -> usize;
319}
320
321const DEFAULT_SECTION_MAX_SIZE_MB: usize = 100;
322
323impl HasMaxSizeBytes for Section {
324    fn max_size_bytes(&self) -> usize {
325        self.max_size_mb
326            .map(|mb| mb * 1024 * 1024)
327            .and_then(|b| usize::try_from(b).ok())
328            .unwrap_or(DEFAULT_SECTION_MAX_SIZE_MB * 1024 * 1024)
329    }
330}
331
332#[allow(unknown_lints, de1301_no_print_macros)] // runs during logging init, before tracing is available
333fn create_file_writer(
334    crate_name: Option<&str>,
335    section: &Section,
336    base_dir: &Path,
337) -> Option<RotWriter> {
338    let file = section.file()?;
339
340    let max_bytes = section.max_size_bytes();
341
342    let p = Path::new(file);
343    let log_path = if p.is_absolute() {
344        p.to_path_buf()
345    } else {
346        base_dir.join(p)
347    };
348
349    match create_rotating_writer_at_path(
350        &log_path,
351        max_bytes,
352        section.max_age_days,
353        section.max_backups,
354    ) {
355        Ok(writer) => Some(writer),
356        Err(e) => {
357            match crate_name {
358                Some(crate_name) => eprintln!(
359                    "Failed to init log file for subsystem '{}': {} ({})",
360                    crate_name,
361                    log_path.to_string_lossy(),
362                    e,
363                ),
364                None => eprintln!(
365                    "Failed to initialize default log file '{}'",
366                    log_path.to_string_lossy()
367                ),
368            }
369            None
370        }
371    }
372}
373
374// ================= ANSI color support =================
375
376/// Returns `true` if stderr supports ANSI color escape codes.
377/// On Windows, also attempts to enable virtual-terminal color processing.
378fn stderr_supports_ansi() -> bool {
379    _ = enable_ansi_support::enable_ansi_support();
380    supports_color::on(supports_color::Stream::Stderr).is_some_and(|level| level.has_basic)
381}
382
383// ================= registry & layers =================
384
385// Keep a guard for non-blocking console to avoid being dropped.
386
387// de1301_no_print_macros: eprintln! is intentional here — if the tracing subscriber
388// fails to initialize we cannot use tracing itself to report the failure.
389#[allow(unknown_lints, de1301_no_print_macros)]
390fn install_subscriber(
391    console_targets: &tracing_subscriber::filter::Targets,
392    file_targets: &tracing_subscriber::filter::Targets,
393    file_router: MultiFileRouter,
394    console_format: ConsoleFormat,
395    #[cfg_attr(not(feature = "otel"), allow(unused_variables))] otel_layer: Option<OtelLayer>,
396) -> WorkerGuard {
397    use tracing_subscriber::{EnvFilter, Registry, fmt, layer::SubscriberExt};
398
399    // RUST_LOG acts as a global upper-bound for console/file if present.
400    // If not set, we don't clamp here — YAML targets drive levels.
401    let env: Option<EnvFilter> = EnvFilter::try_from_default_env().ok();
402
403    // Console writer (non-blocking stderr)
404    let (nb_stderr, guard) = tracing_appender::non_blocking(std::io::stderr());
405
406    // Console fmt layers: text (human-friendly) or JSON (structured).
407    // Only one is active at a time; the other is None.
408    let (console_text, console_json) = match console_format {
409        ConsoleFormat::Text => (
410            Some(
411                fmt::layer()
412                    .with_writer(nb_stderr)
413                    .with_ansi(stderr_supports_ansi())
414                    .with_target(true)
415                    .with_level(true)
416                    .with_timer(fmt::time::UtcTime::rfc_3339())
417                    .with_filter(console_targets.clone()),
418            ),
419            None,
420        ),
421        ConsoleFormat::Json => (
422            None,
423            Some(
424                fmt::layer()
425                    .json()
426                    .with_writer(nb_stderr)
427                    .with_ansi(false)
428                    .with_target(true)
429                    .with_level(true)
430                    .with_timer(fmt::time::UtcTime::rfc_3339())
431                    .with_filter(console_targets.clone()),
432            ),
433        ),
434    };
435
436    // File fmt layer (JSON) if router is not empty
437    let file_layer_opt = if file_router.is_empty() {
438        None
439    } else {
440        Some(
441            fmt::layer()
442                .json()
443                .with_ansi(false)
444                .with_target(true)
445                .with_level(true)
446                .with_timer(fmt::time::UtcTime::rfc_3339())
447                .with_writer(file_router)
448                .with_filter(file_targets.clone()),
449        )
450    };
451
452    // Build subscriber:
453    // 1) OTEL first (because your OtelLayer is bound to `Registry`);
454    //    also filter OTEL by the SAME console targets from YAML.
455    // 2) Then EnvFilter (caps console/file if RUST_LOG is set).
456    // 3) Then console (text or json) + file fmt layers.
457    let subscriber = {
458        let base = Registry::default();
459
460        #[cfg(feature = "otel")]
461        let base = {
462            let otel_opt = otel_layer.map(|otel| otel.with_filter(console_targets.clone()));
463            base.with(otel_opt)
464        };
465        #[cfg(not(feature = "otel"))]
466        let base = base;
467
468        let base = base.with(env);
469        base.with(console_text)
470            .with(console_json)
471            .with(file_layer_opt)
472    };
473
474    if let Err(e) = tracing::subscriber::set_global_default(subscriber) {
475        eprintln!("tracing subscriber init failed: {e}");
476    }
477
478    guard
479}
480// de1301_no_print_macros: same rationale as install_subscriber above.
481#[allow(unknown_lints, de1301_no_print_macros)]
482fn init_minimal(
483    #[cfg_attr(not(feature = "otel"), allow(unused_variables))] otel: Option<OtelLayer>,
484) -> WorkerGuard {
485    use tracing_subscriber::{EnvFilter, Registry, fmt, layer::SubscriberExt};
486
487    // If RUST_LOG is set, it will cap fmt output; otherwise don't clamp here.
488    let env = EnvFilter::try_from_default_env().ok();
489
490    // Console writer (non-blocking stderr)
491    let (nb_stderr, guard) = tracing_appender::non_blocking(std::io::stderr());
492
493    let fmt_layer = fmt::layer()
494        .with_writer(nb_stderr)
495        .with_ansi(stderr_supports_ansi())
496        .with_target(true)
497        .with_timer(fmt::time::UtcTime::rfc_3339());
498
499    // Same ordering: OTEL (if any) first, then EnvFilter, then fmt.
500    let subscriber = {
501        let base = Registry::default();
502
503        #[cfg(feature = "otel")]
504        let base = base.with(otel);
505        #[cfg(not(feature = "otel"))]
506        let base = base;
507
508        base.with(env).with(fmt_layer)
509    };
510
511    // LogTracer is already initialized by the caller (init_logging_unified),
512    // so use set_global_default instead of try_init to avoid double log::set_logger.
513    if let Err(e) = tracing::subscriber::set_global_default(subscriber) {
514        eprintln!("tracing subscriber init failed (minimal): {e}");
515    }
516
517    guard
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523    use std::io::Write;
524
525    /// Helper: run concurrent write test through a given `MakeWriter` and verify output.
526    fn assert_concurrent_writes<'a, W>(writer: &'a W, log_path: &Path)
527    where
528        W: fmt::MakeWriter<'a> + Sync,
529        W::Writer: Write,
530    {
531        const NUM_THREADS: usize = 8;
532        const LINES_PER_THREAD: usize = 500;
533        const TOTAL_LINES: usize = NUM_THREADS * LINES_PER_THREAD;
534
535        std::thread::scope(|s| {
536            for thread_id in 0..NUM_THREADS {
537                s.spawn(move || {
538                    for line_no in 0..LINES_PER_THREAD {
539                        let mut handle = writer.make_writer();
540                        writeln!(handle, "thread={thread_id} line={line_no}")
541                            .expect("write must not fail");
542                    }
543                });
544            }
545        });
546
547        let content = std::fs::read_to_string(log_path).expect("failed to read log file");
548        let lines: Vec<&str> = content.lines().collect();
549
550        assert_eq!(
551            lines.len(),
552            TOTAL_LINES,
553            "expected {TOTAL_LINES} lines but found {} ({} records {})",
554            lines.len(),
555            lines.len().abs_diff(TOTAL_LINES),
556            if lines.len() < TOTAL_LINES {
557                "lost"
558            } else {
559                "extra"
560            },
561        );
562
563        // Verify every line is intact (no interleaved bytes)
564        for (i, line) in lines.iter().enumerate() {
565            assert!(
566                line.starts_with("thread=") && line.contains(" line="),
567                "corrupted line {i}: {line:?}",
568            );
569        }
570    }
571
572    #[test]
573    fn concurrent_writes_are_not_dropped() {
574        let dir = tempfile::tempdir().expect("failed to create temp dir");
575        let log_path = dir.path().join("test.log");
576
577        let writer = create_rotating_writer_at_path(&log_path, 50 * 1024 * 1024, None, Some(1))
578            .expect("failed to create rotating writer");
579
580        assert_concurrent_writes(&writer, &log_path);
581    }
582
583    #[test]
584    fn concurrent_writes_through_routed_writer() {
585        let dir = tempfile::tempdir().expect("failed to create temp dir");
586        let log_path = dir.path().join("routed.log");
587
588        let rot = create_rotating_writer_at_path(&log_path, 50 * 1024 * 1024, None, Some(1))
589            .expect("failed to create rotating writer");
590
591        let router = MultiFileRouter {
592            default: Some(rot),
593            by_prefix: Vec::new(),
594        };
595
596        assert_concurrent_writes(&router, &log_path);
597    }
598
599    /// Helper: create a `RotWriter` for a temp path and return (writer, path).
600    fn tmp_writer(dir: &Path, name: &str) -> (RotWriter, std::path::PathBuf) {
601        let p = dir.join(name);
602        let w = create_rotating_writer_at_path(&p, 50 * 1024 * 1024, None, Some(1))
603            .expect("failed to create rotating writer");
604        (w, p)
605    }
606
607    #[test]
608    fn resolve_for_picks_longest_matching_prefix() {
609        let dir = tempfile::tempdir().expect("failed to create temp dir");
610
611        let (broad, broad_path) = tmp_writer(dir.path(), "broad.log");
612        let (specific, specific_path) = tmp_writer(dir.path(), "specific.log");
613
614        // Write markers so we can tell them apart
615        broad.0.lock().write_all(b"BROAD\n").unwrap();
616        specific.0.lock().write_all(b"SPECIFIC\n").unwrap();
617
618        let router = MultiFileRouter {
619            default: None,
620            by_prefix: vec![
621                ("cf-gears::api_gateway".into(), specific),
622                ("cf-gears".into(), broad),
623            ],
624        };
625
626        // "cf-gears::api_gateway::handler" should match the longer prefix
627        let mut handle = router
628            .resolve_for("cf-gears::api_gateway::handler")
629            .expect("should resolve");
630        handle.write_all(b"routed\n").unwrap();
631        handle.flush().unwrap();
632
633        let specific_content = std::fs::read_to_string(&specific_path).unwrap();
634        assert!(
635            specific_content.contains("routed"),
636            "expected write to land in specific log, got: {specific_content:?}"
637        );
638
639        let broad_content = std::fs::read_to_string(&broad_path).unwrap();
640        assert!(
641            !broad_content.contains("routed"),
642            "write should NOT appear in broad log, got: {broad_content:?}"
643        );
644    }
645
646    /// Verifies that `build_file_router` sorts `by_prefix` by descending length so
647    /// that the longest (most-specific) prefix wins even when the caller registers
648    /// a broad prefix before a specific one.
649    #[test]
650    fn build_file_router_sorts_prefixes_longest_match_wins() {
651        use crate::bootstrap::config::SectionFile;
652
653        let dir = tempfile::tempdir().expect("failed to create temp dir");
654
655        let broad_section = Section {
656            console_format: ConsoleFormat::default(),
657            console_level: None,
658            section_file: Some(SectionFile {
659                file: "broad.log".to_owned(),
660                file_level: None,
661            }),
662            max_age_days: None,
663            max_backups: Some(1),
664            max_size_mb: None,
665        };
666        let specific_section = Section {
667            console_format: ConsoleFormat::default(),
668            console_level: None,
669            section_file: Some(SectionFile {
670                file: "specific.log".to_owned(),
671                file_level: None,
672            }),
673            max_age_days: None,
674            max_backups: Some(1),
675            max_size_mb: None,
676        };
677
678        // Register broad BEFORE specific (reverse of preference order) so that
679        // build_file_router's sort step is what makes the specific prefix win.
680        let config = ConfigData {
681            default_section: None,
682            crate_sections: vec![
683                ("cf-gears".to_owned(), &broad_section),
684                ("cf-gears::api_gateway".to_owned(), &specific_section),
685            ],
686        };
687
688        let router = build_file_router(&config, dir.path());
689
690        let mut handle = router
691            .resolve_for("cf-gears::api_gateway::handler")
692            .expect("should resolve");
693        handle.write_all(b"routed\n").unwrap();
694        handle.flush().unwrap();
695
696        let specific_content = std::fs::read_to_string(dir.path().join("specific.log")).unwrap();
697        assert!(
698            specific_content.contains("routed"),
699            "expected write to land in specific log, got: {specific_content:?}"
700        );
701
702        let broad_content = std::fs::read_to_string(dir.path().join("broad.log")).unwrap();
703        assert!(
704            !broad_content.contains("routed"),
705            "write should NOT appear in broad log, got: {broad_content:?}"
706        );
707    }
708
709    #[test]
710    fn resolve_for_exact_match() {
711        let dir = tempfile::tempdir().expect("failed to create temp dir");
712        let (writer, _) = tmp_writer(dir.path(), "exact.log");
713
714        let router = MultiFileRouter {
715            default: None,
716            by_prefix: vec![("cf-gears".into(), writer)],
717        };
718
719        // Exact match
720        assert!(
721            router.resolve_for("cf-gears").is_some(),
722            "exact target should match"
723        );
724        // Subgear match
725        assert!(
726            router.resolve_for("cf-gears::sub").is_some(),
727            "submodule target should match"
728        );
729        // Non-prefix string must NOT match
730        assert!(
731            router.resolve_for("cf_gears_extra").is_none(),
732            "non-prefix target should not match"
733        );
734        assert!(
735            router.resolve_for("other").is_none(),
736            "unrelated target should not match"
737        );
738    }
739
740    #[test]
741    fn resolve_for_falls_back_to_default() {
742        let dir = tempfile::tempdir().expect("failed to create temp dir");
743        let (default_writer, default_path) = tmp_writer(dir.path(), "default.log");
744
745        default_writer.0.lock().write_all(b"DEFAULT\n").unwrap();
746
747        let router = MultiFileRouter {
748            default: Some(default_writer),
749            by_prefix: vec![],
750        };
751
752        // Unknown target should fall back to default
753        let mut handle = router
754            .resolve_for("unknown_crate::gear")
755            .expect("should fall back to default");
756        handle.write_all(b"fallback\n").unwrap();
757        handle.flush().unwrap();
758
759        let content = std::fs::read_to_string(&default_path).unwrap();
760        assert!(
761            content.contains("fallback"),
762            "expected write to land in default log, got: {content:?}"
763        );
764    }
765}