roteiro 1.6.0

Roteiro: a provenance-tagged knowledge graph for your codebase — structure, intent, and context in one queryable store
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
//! Structured logging / telemetry init (ADR-0011).
//!
//! Roteiro's default logging is unchanged: human-readable text on **stdout**.
//! This module adds an **opt-in second sink** — a rotating file, written in an
//! OpenTelemetry-shaped JSON format a future collector can ingest — without
//! touching the stdout layer. It is deliberately dependency-light: `tracing` +
//! `tracing-subscriber` + `tracing-appender`, no OTLP/network exporter yet (that
//! is the deferred step this module leaves a seam for; see ADR-0011).
//!
//! # The single init seam
//!
//! [`init`] is the one place the subscriber is built. It composes a
//! [`tracing_subscriber::Registry`] with:
//!
//! - a **stdout** layer — the existing human text format, always present, its
//!   default filter `warn` so ordinary runs print nothing new;
//! - an **optional file** layer — added only when file logging is enabled,
//!   filtered at `info` by default, formatted per [`Format`].
//!
//! Both layers honour the `ROTEIRO_LOG` env var for filter directives (e.g.
//! `ROTEIRO_LOG=debug`), the standard `tracing` `EnvFilter` syntax.
//!
//! # OpenTelemetry log field mapping (`otel` / `json` format)
//!
//! Each event is one JSON object per line. Fields map onto the OpenTelemetry
//! [log data model](https://opentelemetry.io/docs/specs/otel/logs/data-model/):
//!
//! | JSON field | OTEL field | Source |
//! |---|---|---|
//! | `time_unix_nano` | `TimeUnixNano` | wall-clock at emit, ns since the Unix epoch |
//! | `observed_time_unix_nano` | `ObservedTimeUnixNano` | same instant (we emit as we observe) |
//! | `severity_number` | `SeverityNumber` | tracing level → OTEL 1/5/9/13/17 |
//! | `severity_text` | `SeverityText` | tracing level name (`TRACE`…`ERROR`) |
//! | `body` | `Body` | the event's `message` |
//! | `attributes` | `Attributes` | remaining event fields + `code.*` source location + `span.*` context |
//! | `resource` | `Resource` | `service.name` / `service.version` (constant for now) |
//!
//! Real `trace_id` / `span_id` correlation joins when the OTLP exporter lands;
//! until then span **context** is surfaced as the `span.name` / `span.path`
//! attributes so the shape is already collector-friendly.

use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};

use anyhow::Context as _;
use tracing::{Event, Level, Subscriber};
use tracing_appender::non_blocking::WorkerGuard;
use tracing_subscriber::fmt::format::{FormatEvent, FormatFields, Writer};
use tracing_subscriber::fmt::{self, FmtContext, FormattedFields};
use tracing_subscriber::layer::SubscriberExt as _;
use tracing_subscriber::registry::LookupSpan;
use tracing_subscriber::util::SubscriberInitExt as _;
use tracing_subscriber::{EnvFilter, Layer, Registry};

use crate::config::{self, TelemetryConfig};

/// The env var read by both layers for `EnvFilter` directives (level filtering).
/// The path/rotation/format env vars (`ROTEIRO_LOG_FILE` / `_ROTATION` / `_FORMAT`)
/// are read by clap directly on the global flags (see `main`).
const ENV_FILTER: &str = "ROTEIRO_LOG";

/// A boxed layer over the shared [`Registry`], so the stdout and (differently
/// typed) file layers can live in one `Vec`.
type BoxLayer = Box<dyn Layer<Registry> + Send + Sync + 'static>;

/// Command-line / env overrides for telemetry, gathered in `main` from the global
/// clap flags (each already flag-or-env, flag winning). They take precedence over
/// the config file, per ADR-0007.
#[derive(Debug, Default, Clone)]
pub struct Overrides {
    /// `--log-file` / `ROTEIRO_LOG_FILE`: explicit log path (enables file logging).
    pub file: Option<String>,
    /// `--log`: enable file logging at the default path when no path is given.
    pub enable_default: bool,
    /// `--log-rotation` / `ROTEIRO_LOG_ROTATION`.
    pub rotation: Option<String>,
    /// `--log-format` / `ROTEIRO_LOG_FORMAT`.
    pub format: Option<String>,
}

/// Rotation cadence for the rolling file appender (time-based; `tracing-appender`
/// does not offer size-based rotation — that is deferred to the OTLP step).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Rotation {
    /// A new file each day (default).
    Daily,
    /// A new file each hour.
    Hourly,
    /// A new file each minute (mainly for tests / high-volume debugging).
    Minutely,
    /// One file, never rotated.
    Never,
}

impl Rotation {
    /// Parse the config/flag string; unknown values are a hard error so a typo
    /// never silently disables rotation.
    fn parse(s: &str) -> anyhow::Result<Self> {
        match s.trim().to_ascii_lowercase().as_str() {
            "daily" => Ok(Self::Daily),
            "hourly" => Ok(Self::Hourly),
            "minutely" => Ok(Self::Minutely),
            "never" => Ok(Self::Never),
            other => anyhow::bail!(
                "invalid telemetry rotation {other:?}: expected daily|hourly|minutely|never"
            ),
        }
    }

    /// Map to the `tracing-appender` rotation policy.
    fn appender(self) -> tracing_appender::rolling::Rotation {
        use tracing_appender::rolling::Rotation as R;
        match self {
            Self::Daily => R::DAILY,
            Self::Hourly => R::HOURLY,
            Self::Minutely => R::MINUTELY,
            Self::Never => R::NEVER,
        }
    }
}

/// On-disk record format for the file layer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Format {
    /// One OpenTelemetry-shaped JSON object per line (see the module docs).
    Otel,
    /// The same human-readable text format stdout uses.
    Text,
}

impl Format {
    /// Parse the config/flag string; `otel` and `json` are synonyms.
    fn parse(s: &str) -> anyhow::Result<Self> {
        match s.trim().to_ascii_lowercase().as_str() {
            "otel" | "json" => Ok(Self::Otel),
            "text" => Ok(Self::Text),
            other => anyhow::bail!("invalid telemetry format {other:?}: expected otel|json|text"),
        }
    }
}

/// The resolved, effective file-logging settings (`None` path ⇒ disabled).
#[derive(Debug, Clone)]
struct Settings {
    /// Absolute (or cwd-relative) path of the log file; `None` ⇒ file logging off.
    path: Option<PathBuf>,
    /// Rotation cadence.
    rotation: Rotation,
    /// On-disk format.
    format: Format,
}

/// Held by `main` for the whole process lifetime. Dropping it flushes and joins
/// the non-blocking appender's worker thread; the `WorkerGuard` **must** outlive
/// all logging, or buffered lines are lost on exit (hence we return it rather than
/// dropping it inside `init`).
#[derive(Debug)]
#[must_use = "hold the telemetry guard for the process lifetime; dropping it stops file logging"]
pub struct Guard(#[allow(dead_code)] Option<WorkerGuard>);

/// Resolve the effective file-logging settings from overrides (CLI/env) over
/// config (project/user) over built-in defaults.
fn resolve(overrides: &Overrides, cfg: &TelemetryConfig) -> anyhow::Result<Settings> {
    // Path precedence: `--log-file`/env > `[telemetry] file` > (`--log` ⇒ default).
    let raw_path = overrides
        .file
        .clone()
        .or_else(|| cfg.file.clone())
        .map(|p| resolve_path(&p));
    let path = match raw_path {
        Some(p) => Some(p),
        None if overrides.enable_default => Some(
            config::default_log_path()
                .context("cannot resolve the default log path (no ROTEIRO_HOME or home dir)")?,
        ),
        None => None,
    };

    let rotation = overrides
        .rotation
        .clone()
        .or_else(|| cfg.rotation.clone())
        .map_or(Ok(Rotation::Daily), |s| Rotation::parse(&s))?;
    let format = overrides
        .format
        .clone()
        .or_else(|| cfg.format.clone())
        .map_or(Ok(Format::Otel), |s| Format::parse(&s))?;

    Ok(Settings {
        path,
        rotation,
        format,
    })
}

/// Resolve a configured log path: expand a leading `~/`, and anchor a relative
/// path under `$ROTEIRO_HOME` (else `~/.roteiro`), so a bare `roteiro.log` lands
/// beside the config rather than in an arbitrary cwd. An absolute path is used
/// verbatim. See [`resolve_path_with`] for the (env-free, testable) core.
fn resolve_path(raw: &str) -> PathBuf {
    resolve_path_with(
        raw,
        config::home_dir().as_deref(),
        config::roteiro_home().as_deref(),
    )
}

/// The env-free core of [`resolve_path`], with the home dir and Roteiro home
/// injected so it can be unit-tested deterministically.
///
/// A leading `~`/`~/` expands against `home`. Crucially, when the home dir is
/// **unavailable** (`home` is `None`, e.g. no `HOME`/`USERPROFILE`), the remainder
/// is anchored under `roteiro_home` (i.e. `$ROTEIRO_HOME`) instead of leaving a
/// literal `~` — which would otherwise make the file logger create `./~/…` under
/// the cwd. A genuinely relative path is likewise anchored under `roteiro_home`.
/// Only when neither home is resolvable does a relative remainder stay relative,
/// but it never keeps a literal `~` segment.
fn resolve_path_with(
    raw: &str,
    home: Option<&std::path::Path>,
    roteiro_home: Option<&std::path::Path>,
) -> PathBuf {
    if raw == "~" || raw.starts_with("~/") {
        let rest = raw.strip_prefix("~/").unwrap_or("");
        if let Some(h) = home {
            return if rest.is_empty() {
                h.to_path_buf()
            } else {
                h.join(rest)
            };
        }
        // No home dir: fall back to `$ROTEIRO_HOME`; if that is missing too, keep
        // the de-tilded remainder relative (never a literal `~`).
        return roteiro_home.map_or_else(|| PathBuf::from(rest), |rh| rh.join(rest));
    }
    let p = PathBuf::from(raw);
    if p.is_absolute() {
        return p;
    }
    roteiro_home.map_or(p.clone(), |rh| rh.join(&p))
}

/// Build the subscriber and install it as the global default (ADR-0011). The
/// stdout layer is always present and unchanged; the file layer is added only when
/// file logging is enabled. Returns the [`Guard`] the caller must hold for the
/// process lifetime.
///
/// # Errors
/// A malformed rotation/format value, an unresolvable default path, a directory
/// that cannot be created, or a global subscriber already being installed.
pub fn init(overrides: &Overrides, cfg: &TelemetryConfig) -> anyhow::Result<Guard> {
    let settings = resolve(overrides, cfg)?;

    // Stdout: the existing human text format, kept default. The writer is set
    // explicitly to `stdout` (rather than relying on the fmt layer's default) so
    // this layer's destination matches its name and the docs, and can't drift.
    // Its filter defaults to `warn` (env `ROTEIRO_LOG`) so ordinary runs print
    // nothing new — stdout stays exactly as it is today.
    let stdout = fmt::layer()
        .with_writer(std::io::stdout)
        .with_filter(env_filter(Level::WARN))
        .boxed();
    let mut layers: Vec<BoxLayer> = vec![stdout];

    let mut guard = None;
    if let Some(path) = &settings.path {
        let (layer, worker) = file_layer(path, settings.rotation, settings.format)?;
        layers.push(layer);
        guard = Some(worker);
    }

    Registry::default()
        .with(layers)
        .try_init()
        .context("installing the global tracing subscriber")?;

    if settings.path.is_some() {
        // A breadcrumb the file captures (info) but stdout suppresses (warn) — so
        // the file has content on every real run without perturbing stdout.
        tracing::info!(
            version = env!("CARGO_PKG_VERSION"),
            "roteiro telemetry initialised"
        );
    }
    Ok(Guard(guard))
}

/// An `EnvFilter` reading `ROTEIRO_LOG` for directives, falling back to `default`.
fn env_filter(default: Level) -> EnvFilter {
    EnvFilter::builder()
        .with_default_directive(default.into())
        .with_env_var(ENV_FILTER)
        .from_env_lossy()
}

/// Build the rotating, non-blocking file layer for `path`. Split out so the init
/// test can compose it onto a **scoped** subscriber without racing on the
/// process-global default `init` installs. Returns the layer and its non-blocking
/// worker guard.
///
/// # Errors
/// The parent directory cannot be created.
fn file_layer(
    path: &std::path::Path,
    rotation: Rotation,
    format: Format,
) -> anyhow::Result<(BoxLayer, WorkerGuard)> {
    let dir = path.parent().filter(|p| !p.as_os_str().is_empty());
    let file_name = path
        .file_name()
        .context("telemetry log path has no file name")?;
    let dir = dir.map_or_else(|| PathBuf::from("."), PathBuf::from);
    std::fs::create_dir_all(&dir)
        .with_context(|| format!("creating log directory {}", dir.display()))?;

    let appender = tracing_appender::rolling::RollingFileAppender::new(
        rotation.appender(),
        &dir,
        std::path::Path::new(file_name),
    );
    let (writer, worker) = tracing_appender::non_blocking(appender);

    // The file always filters at `info` by default (env `ROTEIRO_LOG` still wins),
    // independent of the quieter stdout default.
    let layer = match format {
        Format::Otel => fmt::layer()
            .event_format(OtelJson)
            .with_ansi(false)
            .with_writer(writer)
            .with_filter(env_filter(Level::INFO))
            .boxed(),
        Format::Text => fmt::layer()
            .with_ansi(false)
            .with_writer(writer)
            .with_filter(env_filter(Level::INFO))
            .boxed(),
    };
    Ok((layer, worker))
}

/// The custom event formatter mapping a `tracing` event to an OpenTelemetry-shaped
/// JSON line (see the module docs for the field table).
struct OtelJson;

impl<S, N> FormatEvent<S, N> for OtelJson
where
    S: Subscriber + for<'a> LookupSpan<'a>,
    N: for<'a> FormatFields<'a> + 'static,
{
    fn format_event(
        &self,
        ctx: &FmtContext<'_, S, N>,
        mut writer: Writer<'_>,
        event: &Event<'_>,
    ) -> std::fmt::Result {
        let meta = event.metadata();

        // Event fields: `message` → OTEL `body`, everything else → attributes.
        let mut visitor = JsonVisitor::default();
        event.record(&mut visitor);
        let mut attributes = visitor.attributes;

        // Source location → OTEL `code.*` attributes.
        attributes.insert("code.namespace".into(), meta.target().into());
        if let Some(file) = meta.file() {
            attributes.insert("code.filepath".into(), file.into());
        }
        if let Some(line) = meta.line() {
            attributes.insert("code.lineno".into(), line.into());
        }

        // Span context: the enclosing span names (root→leaf) plus each span's
        // recorded fields. Real trace/span ids arrive with the OTLP exporter.
        if let Some(scope) = ctx.event_scope() {
            let mut names = Vec::new();
            for span in scope.from_root() {
                names.push(span.name().to_owned());
                let ext = span.extensions();
                if let Some(fields) = ext.get::<FormattedFields<N>>()
                    && !fields.fields.is_empty()
                {
                    attributes.insert(
                        format!("span.{}.fields", span.name()),
                        fields.fields.as_str().into(),
                    );
                }
            }
            if let Some(leaf) = names.last() {
                attributes.insert("span.name".into(), leaf.clone().into());
            }
            attributes.insert("span.path".into(), names.join(" > ").into());
        }

        let now = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or_default();
        let unix_nano = u64::try_from(now.as_nanos()).unwrap_or(u64::MAX);
        let (severity_number, severity_text) = severity(*meta.level());

        let record = serde_json::json!({
            "time_unix_nano": unix_nano,
            "observed_time_unix_nano": unix_nano,
            "severity_number": severity_number,
            "severity_text": severity_text,
            "body": visitor.body.unwrap_or_default(),
            "attributes": attributes,
            "resource": {
                "service.name": "roteiro",
                "service.version": env!("CARGO_PKG_VERSION"),
            },
        });

        let line = serde_json::to_string(&record).map_err(|_| std::fmt::Error)?;
        writeln!(writer, "{line}")
    }
}

/// Map a `tracing` level to the OpenTelemetry `(SeverityNumber, SeverityText)`
/// pair. OTEL numbers each range in fives; we use the range base for each level.
fn severity(level: Level) -> (u8, &'static str) {
    match level {
        Level::TRACE => (1, "TRACE"),
        Level::DEBUG => (5, "DEBUG"),
        Level::INFO => (9, "INFO"),
        Level::WARN => (13, "WARN"),
        Level::ERROR => (17, "ERROR"),
    }
}

/// Collects an event's fields into an OTEL `body` (the `message` field) and an
/// `attributes` map (everything else), preserving value types where JSON allows.
#[derive(Default)]
struct JsonVisitor {
    /// The `message` field, mapped to OTEL `Body`.
    body: Option<String>,
    /// All other fields, mapped to OTEL `Attributes`.
    attributes: serde_json::Map<String, serde_json::Value>,
}

impl JsonVisitor {
    /// Route a field: the special `message` becomes the body, all else an attribute.
    fn put(&mut self, field: &tracing::field::Field, value: serde_json::Value) {
        if field.name() == "message" {
            self.body = value
                .as_str()
                .map(ToOwned::to_owned)
                .or_else(|| Some(value.to_string()));
        } else {
            self.attributes.insert(field.name().to_owned(), value);
        }
    }
}

impl tracing::field::Visit for JsonVisitor {
    fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
        self.put(field, value.into());
    }
    fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
        self.put(field, value.into());
    }
    fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
        self.put(field, value.into());
    }
    fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
        self.put(field, value.into());
    }
    fn record_f64(&mut self, field: &tracing::field::Field, value: f64) {
        self.put(field, value.into());
    }
    fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
        self.put(field, format!("{value:?}").into());
    }
}

#[cfg(test)]
mod tests {
    use super::{Format, Overrides, Rotation, file_layer, resolve, resolve_path_with};
    use crate::config::TelemetryConfig;
    use std::path::{Path, PathBuf};
    use tracing_subscriber::layer::SubscriberExt as _;

    /// Path resolution, including the no-HOME fallback: a `~/…` path resolves under
    /// the home dir when it's available, but under `$ROTEIRO_HOME` when the home dir
    /// can't be resolved — never as a literal `~` segment under the cwd (which would
    /// make the file logger write `./~/…`). Relative paths anchor under Roteiro home.
    #[test]
    fn resolve_path_expands_tilde_and_falls_back_to_roteiro_home() {
        let home = Path::new("/home/u");
        let rh = Path::new("/iso/.roteiro");

        // `~/…` with a home dir available → under home.
        assert_eq!(
            resolve_path_with("~/.roteiro/logs/roteiro.log", Some(home), Some(rh)),
            PathBuf::from("/home/u/.roteiro/logs/roteiro.log")
        );
        // Bare `~` → the home dir itself.
        assert_eq!(resolve_path_with("~", Some(home), Some(rh)), home);

        // No HOME/USERPROFILE, but ROTEIRO_HOME set → anchor under it, NOT `./~/…`.
        let got = resolve_path_with("~/logs/roteiro.log", None, Some(rh));
        assert_eq!(got, PathBuf::from("/iso/.roteiro/logs/roteiro.log"));
        assert!(
            !got.components().any(|c| c.as_os_str() == "~"),
            "no literal ~ segment survives: {got:?}"
        );

        // Neither home resolvable → relative remainder, still no literal `~`.
        let got = resolve_path_with("~/logs/x.log", None, None);
        assert_eq!(got, PathBuf::from("logs/x.log"));

        // A relative path anchors under Roteiro home; an absolute path is verbatim.
        assert_eq!(
            resolve_path_with("roteiro.log", Some(home), Some(rh)),
            PathBuf::from("/iso/.roteiro/roteiro.log")
        );
        assert_eq!(
            resolve_path_with("/var/log/roteiro.log", Some(home), Some(rh)),
            PathBuf::from("/var/log/roteiro.log")
        );
    }

    /// `resolve` honours precedence: overrides beat config, and defaults fill the
    /// rest; an unset path with no `--log` means file logging is disabled.
    #[test]
    fn resolve_precedence_and_defaults() {
        // All unset ⇒ disabled, with the documented defaults for the other knobs.
        let s = resolve(&Overrides::default(), &TelemetryConfig::default()).expect("resolve");
        assert!(s.path.is_none(), "no file/flag ⇒ file logging off");
        assert_eq!(s.rotation, Rotation::Daily);
        assert_eq!(s.format, Format::Otel);

        // Config sets a path + knobs; an override for rotation wins over config.
        let cfg = TelemetryConfig {
            file: Some("/var/log/roteiro.log".to_owned()),
            rotation: Some("hourly".to_owned()),
            format: Some("json".to_owned()),
        };
        let over = Overrides {
            rotation: Some("never".to_owned()),
            ..Default::default()
        };
        let s = resolve(&over, &cfg).expect("resolve");
        assert_eq!(
            s.path.as_deref(),
            Some(std::path::Path::new("/var/log/roteiro.log"))
        );
        assert_eq!(s.rotation, Rotation::Never, "override beats config");
        assert_eq!(s.format, Format::Otel, "json is an alias of otel");

        // `--log-file` overrides a config path.
        let over = Overrides {
            file: Some("/tmp/explicit.log".to_owned()),
            ..Default::default()
        };
        let s = resolve(&over, &cfg).expect("resolve");
        assert_eq!(
            s.path.as_deref(),
            Some(std::path::Path::new("/tmp/explicit.log"))
        );

        // A bad rotation/format value is a hard error, never silently ignored.
        let bad = TelemetryConfig {
            rotation: Some("weekly".to_owned()),
            ..Default::default()
        };
        assert!(
            resolve(&Overrides::default(), &bad).is_err(),
            "bad rotation errors"
        );
    }

    /// The init seam, wired end-to-end: given a temp dir, an `info!` event lands in
    /// the rotating file as one OTEL-shaped JSON line carrying the expected fields.
    /// Uses a **scoped** subscriber (`with_default`) so it never races on the
    /// process-global default that other tests / `init` install.
    #[test]
    fn otel_file_layer_writes_parsable_line_with_otel_fields() {
        let dir = std::env::temp_dir().join(format!("roteiro-telemetry-{}", std::process::id()));
        std::fs::remove_dir_all(&dir).ok();
        // `never` rotation ⇒ the file is exactly this name (no date suffix), so the
        // test asserts a deterministic path. Rotation wiring itself is covered by
        // `Rotation::appender`; we do not assert on wall-clock rotation.
        let path = dir.join("roteiro.log");
        let (layer, guard) =
            file_layer(&path, Rotation::Never, Format::Otel).expect("build file layer");

        let subscriber = tracing_subscriber::registry().with(layer);
        tracing::subscriber::with_default(subscriber, || {
            let span = tracing::info_span!("ingest", repo = "demo");
            let _e = span.enter();
            tracing::info!(nodes = 42, "sync complete");
        });
        // Flush + join the non-blocking worker so the line is on disk before we read.
        drop(guard);

        let contents = std::fs::read_to_string(&path).expect("log file exists");
        let line = contents
            .lines()
            .find(|l| l.contains("sync complete"))
            .expect("our line");
        let v: serde_json::Value = serde_json::from_str(line).expect("valid JSON line");

        // OTEL log data-model fields are present and correctly mapped.
        assert_eq!(v["body"], "sync complete", "message ⇒ OTEL body");
        assert_eq!(v["severity_text"], "INFO");
        assert_eq!(v["severity_number"], 9);
        assert!(
            v["time_unix_nano"].as_u64().is_some_and(|t| t > 0),
            "timestamp present as ns since epoch"
        );
        assert_eq!(
            v["attributes"]["nodes"], 42,
            "non-message field ⇒ attribute"
        );
        assert_eq!(
            v["attributes"]["span.name"], "ingest",
            "span context surfaced"
        );
        assert_eq!(v["resource"]["service.name"], "roteiro");

        std::fs::remove_dir_all(&dir).ok();
    }

    /// A layer that just counts the events it sees, for asserting whether a filter
    /// let an event through.
    #[derive(Clone, Default)]
    struct CountingLayer(std::sync::Arc<std::sync::atomic::AtomicUsize>);
    impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for CountingLayer {
        fn on_event(
            &self,
            _event: &tracing::Event<'_>,
            _ctx: tracing_subscriber::layer::Context<'_, S>,
        ) {
            self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
        }
    }

    /// Count the native-target events of `level` that survive a filter with the
    /// given default level. Emits on the `llama.cpp` target — exactly what the
    /// `send_logs_to_tracing` bridge tags llama.cpp/ggml lines with — under a
    /// scoped subscriber, so it never touches the process-global default.
    fn native_events_passing(default: tracing::Level, event_level: tracing::Level) -> usize {
        use tracing_subscriber::Layer as _;
        let counter = CountingLayer::default();
        // Build the filter from the default level only (no env), so the assertion
        // is deterministic regardless of any `ROTEIRO_LOG` in the environment.
        let filter = tracing_subscriber::EnvFilter::builder()
            .with_default_directive(default.into())
            .parse_lossy("");
        let subscriber = tracing_subscriber::registry().with(counter.clone().with_filter(filter));
        tracing::subscriber::with_default(subscriber, || match event_level {
            tracing::Level::INFO => {
                tracing::info!(target: "llama.cpp", "llama_model_loader: loaded meta data");
            }
            tracing::Level::WARN => tracing::warn!(target: "ggml", "ggml warning"),
            _ => tracing::debug!(target: "llama.cpp", "create_tensor: loading"),
        });
        counter.0.load(std::sync::atomic::Ordering::SeqCst)
    }

    /// The native llama.cpp/ggml logs (routed via `send_logs_to_tracing`) are
    /// gated exactly like any other event: the noisy model-loader **INFO** wall is
    /// suppressed at the stdout default (`warn`) but captured at the file default
    /// (`info`), while native **WARN**/**ERROR** always surface — even on stdout.
    /// This is the whole point of the bridge, tested without loading a model.
    #[test]
    fn native_logs_are_level_gated_like_any_event() {
        use tracing::Level;
        // The INFO model-loader wall: off at the stdout default, on at the file default.
        assert_eq!(
            native_events_passing(Level::WARN, Level::INFO),
            0,
            "native INFO wall is quiet on stdout by default (warn filter)"
        );
        assert_eq!(
            native_events_passing(Level::INFO, Level::INFO),
            1,
            "native INFO wall is captured when the level is info (file default / --log)"
        );
        // Opt-in: at debug, even the DEBUG native lines surface.
        assert_eq!(
            native_events_passing(Level::DEBUG, Level::DEBUG),
            1,
            "native DEBUG lines surface at ROTEIRO_LOG=debug"
        );
        assert_eq!(
            native_events_passing(Level::WARN, Level::DEBUG),
            0,
            "native DEBUG lines stay hidden by default"
        );
        // Native warnings/errors are never swallowed, even at the quiet stdout default.
        assert_eq!(
            native_events_passing(Level::WARN, Level::WARN),
            1,
            "native WARN/ERROR always surface"
        );
    }
}