Skip to main content

omni_dev/
request_log.rs

1//! Append-only, local invocation + HTTP request log (`log.jsonl`).
2//!
3//! Every `omni-dev` invocation appends one `kind: "invocation"` line; every
4//! outbound HTTP request made by one of the integration clients appends one
5//! `kind: "http"` line correlated to it by a shared `invocation_id`. The log
6//! is **local-machine state** written under the platform state/data directory
7//! (`0700` dir / `0600` file, the same posture as [`crate::daemon::paths`]).
8//!
9//! Design invariants:
10//!
11//! - **Best effort.** [`record`] swallows every error (logging only at
12//!   `tracing::debug`); a logging failure can never change the program's exit
13//!   code. Honors `OMNI_DEV_LOG_DISABLE=1` for an absolute opt-out.
14//! - **No secrets.** Auth headers/tokens are never written; only a non-secret
15//!   `auth_principal` identity is kept. Headers are redacted centrally
16//!   ([`redact_headers`]), secret-bearing URL query/fragment parameter values
17//!   are redacted (`redact_url`) before writing, and request/response bodies
18//!   are opt-in via `OMNI_DEV_LOG_BODIES=1`.
19//! - **Forward compatible.** A single [`LogRecord`] is used for both writing
20//!   and reading: every field is `#[serde(default)]`, and every optional field
21//!   is `skip_serializing_if`, so a newer reader never chokes on an older line
22//!   and an older reader never chokes on a newer one — the same forward-rolling
23//!   contract the daemon wire types use.
24
25use std::collections::BTreeMap;
26use std::io::Write;
27use std::path::{Path, PathBuf};
28use std::sync::OnceLock;
29use std::time::{Duration, Instant};
30
31use chrono::{DateTime, SecondsFormat, Utc};
32use serde::{Deserialize, Serialize};
33
34/// Default log file name under the runtime directory.
35const LOG_FILE_NAME: &str = "log.jsonl";
36
37/// Number of rotated log files kept by default when
38/// [`OMNI_DEV_LOG_MAX_SIZE`](rotation_config) enables rotation but
39/// `OMNI_DEV_LOG_KEEP_FILES` is unset. (Rotation on write is unix-only.)
40#[cfg(unix)]
41const DEFAULT_KEEP_FILES: u32 = 3;
42
43/// Which kind of record a line holds. Unknown future kinds deserialize to
44/// [`RecordKind::Unknown`] rather than failing the read.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
46#[serde(rename_all = "lowercase")]
47pub enum RecordKind {
48    /// One per process invocation (or per MCP tool call).
49    #[default]
50    Invocation,
51    /// One per outbound HTTP request.
52    Http,
53    /// One per `gh` CLI subprocess invocation (the only path to the GitHub API;
54    /// the token never enters our process, so these are subprocess records, not
55    /// [`RecordKind::Http`]). See `crate::github_metrics`.
56    Gh,
57    /// A kind written by a newer version that this reader does not know.
58    #[serde(other)]
59    Unknown,
60}
61
62impl RecordKind {
63    /// Stable lowercase name, used for display and JSON map keys (matches the
64    /// `serde(rename_all = "lowercase")` wire form).
65    #[must_use]
66    pub fn as_str(self) -> &'static str {
67        match self {
68            Self::Invocation => "invocation",
69            Self::Http => "http",
70            Self::Gh => "gh",
71            Self::Unknown => "unknown",
72        }
73    }
74}
75
76/// What drove an invocation. Unknown future sources deserialize to
77/// [`Source::Unknown`].
78#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
79#[serde(rename_all = "lowercase")]
80pub enum Source {
81    /// A direct `omni-dev` CLI invocation.
82    #[default]
83    Cli,
84    /// An `omni-dev-mcp` tool call.
85    Mcp,
86    /// Work performed inside the long-lived daemon process.
87    Daemon,
88    /// A source written by a newer version that this reader does not know.
89    #[serde(other)]
90    Unknown,
91}
92
93/// One line of the log. Used for both writing and reading; every field is
94/// `#[serde(default)]` (tolerant reads) and every optional field is
95/// `skip_serializing_if` (compact, forward-compatible writes).
96#[derive(Debug, Clone, Default, Serialize, Deserialize)]
97pub struct LogRecord {
98    // --- Core fields (present on every record) ---
99    /// Per-record, time-sortable id (see [`new_id`]).
100    #[serde(default)]
101    pub id: String,
102    /// Shared by an invocation record and every HTTP record it spawned.
103    #[serde(default)]
104    pub invocation_id: String,
105    /// Discriminates the record type.
106    #[serde(default)]
107    pub kind: RecordKind,
108    /// RFC3339 timestamp with milliseconds.
109    #[serde(default)]
110    pub timestamp: String,
111    /// Host the record was written on.
112    #[serde(default)]
113    pub hostname: String,
114    /// Writing process id.
115    #[serde(default)]
116    pub pid: u32,
117    /// `omni-dev` version that wrote the record.
118    #[serde(default)]
119    pub omni_dev_version: String,
120    /// Working directory at write time.
121    #[serde(default)]
122    pub cwd: String,
123    /// OS user that owns the process.
124    #[serde(default)]
125    pub system_user: String,
126
127    // --- `kind: "invocation"` fields ---
128    /// Resolved clap subcommand path, e.g. `["jira","read"]`.
129    #[serde(default, skip_serializing_if = "Vec::is_empty")]
130    pub command: Vec<String>,
131    /// Full argv.
132    #[serde(default, skip_serializing_if = "Vec::is_empty")]
133    pub command_line: Vec<String>,
134    /// Process exit code (0 success, 1 error — matches `die`).
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub exit_code: Option<i32>,
137    /// Wall time of the whole invocation.
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub duration_ms: Option<u64>,
140    /// Whitelisted, non-secret `OMNI_DEV_*` env snapshot.
141    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
142    pub env: BTreeMap<String, String>,
143    /// What drove the run (`cli`/`mcp`/`daemon`).
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub source: Option<Source>,
146    /// When `source = mcp`, the tool name that drove the run.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub mcp_tool: Option<String>,
149
150    // --- `kind: "http"` fields ---
151    /// Coarse service tag (`jira`/`confluence`/`datadog`/…) for fast filtering.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub service: Option<String>,
154    /// HTTP method.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub method: Option<String>,
157    /// Request URL.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub url: Option<String>,
160    /// Response status; absent on a network/transport error.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub status_code: Option<u16>,
163    /// Elapsed time of the request.
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub elapsed_ms: Option<u64>,
166    /// True when the request ran inside the daemon (bridge/Snowflake pool).
167    #[serde(default, skip_serializing_if = "is_false")]
168    pub via_daemon: bool,
169    /// Which pooled daemon session served the request.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub daemon_session_id: Option<String>,
172    /// Non-secret identity actually used (token id / OAuth principal) — never
173    /// the secret itself.
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub auth_principal: Option<String>,
176    /// Redacted request headers (only when `OMNI_DEV_LOG_HEADERS=1`).
177    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
178    pub request_headers: BTreeMap<String, String>,
179    /// Redacted response headers (only when `OMNI_DEV_LOG_HEADERS=1`).
180    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
181    pub response_headers: BTreeMap<String, String>,
182    /// Request body (only when `OMNI_DEV_LOG_BODIES=1`).
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub request_body: Option<String>,
185    /// Response body (only when `OMNI_DEV_LOG_BODIES=1`).
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub response_body: Option<String>,
188    /// Free-form correlation tags.
189    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
190    pub context: BTreeMap<String, String>,
191
192    // --- shared optional ---
193    /// Top-level error chain (invocation) or per-request error (http).
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub error: Option<String>,
196}
197
198/// `skip_serializing_if` predicate for `bool` fields that default to `false`.
199#[allow(clippy::trivially_copy_pass_by_ref)] // serde requires `fn(&T) -> bool`
200fn is_false(b: &bool) -> bool {
201    !*b
202}
203
204impl LogRecord {
205    /// Builds a record carrying only the always-present core fields.
206    fn new(kind: RecordKind, invocation_id: String) -> Self {
207        Self {
208            id: new_id(),
209            invocation_id,
210            kind,
211            timestamp: now_rfc3339_millis(),
212            hostname: hostname(),
213            pid: std::process::id(),
214            omni_dev_version: crate::VERSION.to_string(),
215            cwd: cwd(),
216            system_user: system_user(),
217            ..Self::default()
218        }
219    }
220}
221
222/// The per-invocation context every record is stamped with.
223///
224/// Held once per process in [`GLOBAL`] (CLI/daemon) and overridden per task in
225/// [`CTX`] (the multiplexed MCP server), so HTTP records can find their parent
226/// invocation without threading state through every call site.
227#[derive(Debug, Clone)]
228pub struct RequestLogContext {
229    /// Shared id linking an invocation to the HTTP it spawned.
230    pub invocation_id: String,
231    /// What drove the run.
232    pub source: Source,
233    /// MCP tool name when `source = mcp`.
234    pub mcp_tool: Option<String>,
235}
236
237impl Default for RequestLogContext {
238    fn default() -> Self {
239        Self {
240            invocation_id: new_id(),
241            source: Source::Cli,
242            mcp_tool: None,
243        }
244    }
245}
246
247impl RequestLogContext {
248    /// A CLI context with a freshly minted invocation id.
249    pub fn cli() -> Self {
250        Self {
251            invocation_id: new_id(),
252            source: Source::Cli,
253            mcp_tool: None,
254        }
255    }
256
257    /// An MCP context for a single tool call.
258    pub fn mcp(tool: impl Into<String>) -> Self {
259        Self {
260            invocation_id: new_id(),
261            source: Source::Mcp,
262            mcp_tool: Some(tool.into()),
263        }
264    }
265}
266
267static GLOBAL: OnceLock<RequestLogContext> = OnceLock::new();
268
269tokio::task_local! {
270    /// Per-task context override, set around each MCP tool dispatch.
271    pub static CTX: RequestLogContext;
272}
273
274/// Installs the process-global context. The first call wins (the CLI/daemon
275/// shell sets it once, very early); later calls are ignored.
276pub fn set_global(ctx: RequestLogContext) {
277    let _ = GLOBAL.set(ctx);
278}
279
280/// Resolves the active context: task-local override first, then the
281/// process-global default, then a synthesized fallback.
282pub fn current_context() -> RequestLogContext {
283    if let Ok(ctx) = CTX.try_with(RequestLogContext::clone) {
284        return ctx;
285    }
286    if let Some(ctx) = GLOBAL.get() {
287        return ctx.clone();
288    }
289    RequestLogContext::default()
290}
291
292/// Runs `fut` with the active context's `invocation_id` replaced by
293/// `origin_id`, preserving `source` and `mcp_tool`.
294///
295/// The daemon and the browser bridge scope this around a request they serve on
296/// behalf of a CLI/MCP client, so the HTTP records that request spawns
297/// correlate to the *originating* invocation rather than the server's own
298/// (#1198). `source` is deliberately preserved: a request served inside the
299/// daemon keeps `source = Daemon`, so `via_daemon` detection is unaffected while
300/// `invocation_id` now points at the caller's invocation record.
301pub async fn scope_origin_id<F, T>(origin_id: String, fut: F) -> T
302where
303    F: std::future::Future<Output = T>,
304{
305    let mut ctx = current_context();
306    ctx.invocation_id = origin_id;
307    CTX.scope(ctx, fut).await
308}
309
310/// Whether logging is disabled entirely (`OMNI_DEV_LOG_DISABLE=1`).
311pub fn disabled() -> bool {
312    env_flag("OMNI_DEV_LOG_DISABLE")
313}
314
315/// Whether request/response bodies may be recorded (`OMNI_DEV_LOG_BODIES=1`).
316pub fn bodies_enabled() -> bool {
317    env_flag("OMNI_DEV_LOG_BODIES")
318}
319
320/// Whether (redacted) headers may be recorded (`OMNI_DEV_LOG_HEADERS=1`).
321pub fn headers_enabled() -> bool {
322    env_flag("OMNI_DEV_LOG_HEADERS")
323}
324
325/// Reads a boolean-ish env var (`1`/`true`/`yes`, case-insensitive).
326fn env_flag(name: &str) -> bool {
327    std::env::var(name).is_ok_and(|v| {
328        let v = v.trim().to_ascii_lowercase();
329        v == "1" || v == "true" || v == "yes"
330    })
331}
332
333/// Resolves the log file path: `OMNI_DEV_LOG_FILE` override, else
334/// `state_dir` (falling back to `data_dir`) joined with `omni-dev/log.jsonl`.
335pub fn log_file_path() -> Option<PathBuf> {
336    if let Ok(path) = std::env::var("OMNI_DEV_LOG_FILE") {
337        if !path.is_empty() {
338            return Some(PathBuf::from(path));
339        }
340    }
341    let base = dirs::state_dir().or_else(dirs::data_dir)?;
342    Some(base.join("omni-dev").join(LOG_FILE_NAME))
343}
344
345/// Appends one record. Best effort: every error is swallowed (logged at
346/// `tracing::debug`) so logging can never affect the caller's exit code.
347pub fn record(entry: &LogRecord) {
348    if disabled() {
349        return;
350    }
351    if let Err(e) = try_record(entry) {
352        tracing::debug!("request_log: failed to append record: {e}");
353    }
354}
355
356/// The fallible append used by [`record`]; all errors flow back to be swallowed.
357fn try_record(entry: &LogRecord) -> anyhow::Result<()> {
358    use anyhow::Context;
359
360    let path = log_file_path().context("could not resolve the log file path")?;
361    // Only create and tighten the parent when it's missing — re-`chmod`ing an
362    // existing dir (e.g. a user-chosen OMNI_DEV_LOG_FILE location, or a shared
363    // temp dir) is both wrong and may fail; the file itself is always 0600.
364    if let Some(parent) = path.parent() {
365        if !parent.as_os_str().is_empty() && !parent.exists() {
366            crate::daemon::paths::ensure_dir_0700(parent)?;
367        }
368    }
369    let mut line = serde_json::to_string(entry).context("failed to serialize record")?;
370    line.push('\n');
371    append_line(&path, &line)?;
372    Ok(())
373}
374
375/// Appends a single line with `O_APPEND | O_CREATE`, creating the file `0600`.
376/// A pre-existing looser-perm file (an older version's, or a user-set
377/// `OMNI_DEV_LOG_FILE` target) is re-tightened to `0600` on every open, via
378/// the handle so there is no path race (#1139).
379/// When bodies are enabled (lines may exceed the atomic-write size) an advisory
380/// exclusive lock guards the write; the common no-body path relies on
381/// `O_APPEND` single-write atomicity and takes no lock.
382#[cfg(unix)]
383fn append_line(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
384    use std::os::unix::fs::OpenOptionsExt;
385
386    // Opt-in size-capped rotation takes over the write: it must stat, maybe
387    // rotate, then open a fresh file, all under a stable-path lock (#1121).
388    if let Some(cfg) = rotation_config() {
389        return append_with_rotation(path, line, &cfg);
390    }
391
392    let file = std::fs::OpenOptions::new()
393        .append(true)
394        .create(true)
395        .mode(0o600)
396        .open(path)?;
397    crate::daemon::paths::ensure_handle_0600(&file)?;
398
399    if bodies_enabled() {
400        match nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusive) {
401            Ok(mut guard) => {
402                guard.write_all(line.as_bytes())?;
403            }
404            Err((mut file, _)) => {
405                file.write_all(line.as_bytes())?;
406            }
407        }
408    } else {
409        let mut file = file;
410        file.write_all(line.as_bytes())?;
411    }
412    Ok(())
413}
414
415/// Non-unix fallback: `O_APPEND | O_CREATE` single write, no advisory lock and
416/// no mode tightening (those are unix concepts). Size-capped rotation is a
417/// unix-only feature and is not applied here.
418#[cfg(not(unix))]
419fn append_line(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
420    let mut file = std::fs::OpenOptions::new()
421        .append(true)
422        .create(true)
423        .open(path)?;
424    file.write_all(line.as_bytes())?;
425    Ok(())
426}
427
428// --- Size management: rotation on write + `omni-dev log prune` ---
429//
430// The log is default-on for every invocation and every outbound request, so on
431// an active machine it would otherwise grow without bound (#1121). Two bounds
432// are offered, both opt-in:
433//
434//   * Automatic size-capped rotation on write, gated on `OMNI_DEV_LOG_MAX_SIZE`
435//     (+ `OMNI_DEV_LOG_KEEP_FILES`) — numbered `log.jsonl.1`, `.2`, … files.
436//   * The explicit `omni-dev log prune` command (age- and/or size-based), which
437//     rewrites the file in place via a same-dir temp file + atomic rename.
438
439/// Returns `path` with `suffix` appended to its final component (kept in the
440/// same directory), e.g. `…/log.jsonl` + `.1` → `…/log.jsonl.1`.
441fn sibling(path: &Path, suffix: &str) -> PathBuf {
442    let mut name = path.as_os_str().to_owned();
443    name.push(suffix);
444    PathBuf::from(name)
445}
446
447/// Parses a human byte size: a number (with optional decimal) and an optional
448/// unit suffix — `b` (bytes, the default), `k`/`kb`/`kib`, `m`/`mb`/`mib`,
449/// `g`/`gb`/`gib` (case-insensitive, all binary/1024-based).
450pub(crate) fn parse_size(s: &str) -> anyhow::Result<u64> {
451    use anyhow::Context as _;
452
453    let lower = s.trim().to_ascii_lowercase();
454    if lower.is_empty() {
455        anyhow::bail!("empty size (expected e.g. 10mb, 512kb, 1048576)");
456    }
457    let split = lower
458        .find(|c: char| !c.is_ascii_digit() && c != '.')
459        .unwrap_or(lower.len());
460    let (num, unit) = lower.split_at(split);
461    let value: f64 = num
462        .parse()
463        .with_context(|| format!("invalid size number: {s}"))?;
464    if !value.is_finite() || value < 0.0 {
465        anyhow::bail!("invalid size: {s}");
466    }
467    let mult: u64 = match unit.trim() {
468        "" | "b" => 1,
469        "k" | "kb" | "kib" => 1024,
470        "m" | "mb" | "mib" => 1024 * 1024,
471        "g" | "gb" | "gib" => 1024 * 1024 * 1024,
472        other => anyhow::bail!("invalid size unit: {other} (use b, kb, mb, or gb)"),
473    };
474    Ok((value * mult as f64) as u64)
475}
476
477/// Resolved rotation policy from the environment. `None` means rotation is off
478/// (the default): `OMNI_DEV_LOG_MAX_SIZE` unset, empty, invalid, or `0`.
479/// Rotation on write is a unix-only feature.
480#[cfg(unix)]
481struct RotationConfig {
482    /// Rotate before an append that would push the file past this many bytes.
483    max_size: u64,
484    /// Number of rotated `log.jsonl.N` files to retain.
485    keep_files: u32,
486}
487
488/// Reads the rotation policy from `OMNI_DEV_LOG_MAX_SIZE` /
489/// `OMNI_DEV_LOG_KEEP_FILES`. A set-but-invalid `OMNI_DEV_LOG_MAX_SIZE` logs at
490/// debug and disables rotation rather than failing the write.
491#[cfg(unix)]
492fn rotation_config() -> Option<RotationConfig> {
493    let raw = std::env::var("OMNI_DEV_LOG_MAX_SIZE").ok()?;
494    if raw.trim().is_empty() {
495        return None;
496    }
497    let max_size = match parse_size(&raw) {
498        Ok(0) => return None,
499        Ok(n) => n,
500        Err(e) => {
501            tracing::debug!("request_log: ignoring invalid OMNI_DEV_LOG_MAX_SIZE: {e}");
502            return None;
503        }
504    };
505    let keep_files = std::env::var("OMNI_DEV_LOG_KEEP_FILES")
506        .ok()
507        .and_then(|v| v.trim().parse::<u32>().ok())
508        .unwrap_or(DEFAULT_KEEP_FILES);
509    Some(RotationConfig {
510        max_size,
511        keep_files,
512    })
513}
514
515/// Rotates `log.jsonl` → `log.jsonl.1`, shifting existing numbered files up and
516/// dropping any beyond `keep_files` (`keep_files == 0` simply discards the
517/// current file). Rotated files inherit the `0600` mode of their source.
518#[cfg(unix)]
519fn rotate(path: &Path, keep_files: u32) -> anyhow::Result<()> {
520    if keep_files == 0 {
521        // Retain no history: dropping the current file lets the caller start a
522        // fresh one on the following append.
523        let _ = std::fs::remove_file(path);
524        return Ok(());
525    }
526    // Drop the oldest retained file, then shift .(N-1) → .N … .1 → .2.
527    let _ = std::fs::remove_file(sibling(path, &format!(".{keep_files}")));
528    for i in (1..keep_files).rev() {
529        let from = sibling(path, &format!(".{i}"));
530        if from.exists() {
531            std::fs::rename(&from, sibling(path, &format!(".{}", i + 1)))?;
532        }
533    }
534    std::fs::rename(path, sibling(path, ".1"))?;
535    Ok(())
536}
537
538/// Size-capped append (unix): under an exclusive lock on a stable `<log>.lock`
539/// file — so all rotation-aware writers serialize on an inode that is never
540/// itself rotated — stat the log, rotate if this line would push a non-empty
541/// file past the cap, then append to the (possibly fresh) file. A rotation
542/// failure is logged at debug and the line is still appended (best effort).
543#[cfg(unix)]
544fn append_with_rotation(path: &Path, line: &str, cfg: &RotationConfig) -> anyhow::Result<()> {
545    use std::os::unix::fs::OpenOptionsExt;
546
547    let lock_path = sibling(path, ".lock");
548    let lock_file = std::fs::OpenOptions::new()
549        .create(true)
550        .write(true)
551        .truncate(false)
552        .mode(0o600)
553        .open(&lock_path)?;
554    crate::daemon::paths::ensure_handle_0600(&lock_file)?;
555    // Hold the lock for the whole check-rotate-append. If the lock cannot be
556    // taken, fall through unlocked rather than dropping the record.
557    let _guard = nix::fcntl::Flock::lock(lock_file, nix::fcntl::FlockArg::LockExclusive).ok();
558
559    let current = std::fs::metadata(path).map_or(0, |m| m.len());
560    if current > 0 && current.saturating_add(line.len() as u64) > cfg.max_size {
561        if let Err(e) = rotate(path, cfg.keep_files) {
562            tracing::debug!("request_log: rotation failed, appending without rotating: {e}");
563        }
564    }
565
566    let mut file = std::fs::OpenOptions::new()
567        .append(true)
568        .create(true)
569        .mode(0o600)
570        .open(path)?;
571    crate::daemon::paths::ensure_handle_0600(&file)?;
572    file.write_all(line.as_bytes())?;
573    Ok(())
574}
575
576/// Options controlling [`prune`].
577pub struct PruneOptions {
578    /// Drop records whose timestamp is strictly older than this cutoff. A
579    /// record with a missing/unparseable timestamp (or a malformed line) is
580    /// conservatively kept.
581    pub older_than: Option<DateTime<Utc>>,
582    /// After age pruning, drop the oldest records until the file is at most
583    /// this many bytes. At least the single most recent record is always kept.
584    pub max_size: Option<u64>,
585    /// Compute and report the outcome without modifying the file.
586    pub dry_run: bool,
587}
588
589/// What a [`prune`] run did (or, when `dry_run`, would do).
590pub struct PruneOutcome {
591    /// Records removed.
592    pub removed: usize,
593    /// Records retained.
594    pub kept: usize,
595    /// File size before.
596    pub bytes_before: u64,
597    /// File size after (the size the retained records occupy).
598    pub bytes_after: u64,
599}
600
601/// Prunes the log at `path` by age and/or size, rewriting it in place.
602///
603/// Non-empty lines are retained by two successive filters: age (`older_than`)
604/// then size (`max_size`, keeping the most recent records that fit). The kept
605/// lines are written to a same-directory temp file (`0600` on unix) and
606/// atomically renamed over the original, so a reader never sees a half-written
607/// file. A missing log is a no-op; a no-change prune skips the rewrite (leaving
608/// the file's inode — and any concurrent appends — untouched).
609pub fn prune(path: &Path, opts: &PruneOptions) -> anyhow::Result<PruneOutcome> {
610    use anyhow::Context as _;
611
612    let data = match std::fs::read(path) {
613        Ok(data) => data,
614        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
615            return Ok(PruneOutcome {
616                removed: 0,
617                kept: 0,
618                bytes_before: 0,
619                bytes_after: 0,
620            });
621        }
622        Err(e) => return Err(e).context("failed to read the log file"),
623    };
624    let bytes_before = data.len() as u64;
625    let text = String::from_utf8_lossy(&data);
626
627    // Every non-empty line, then the subset passing the age filter (both in
628    // original — chronological — order).
629    let all: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
630    let aged: Vec<&str> = all
631        .iter()
632        .copied()
633        .filter(|line| keep_by_age(line, opts.older_than))
634        .collect();
635
636    let kept: &[&str] = match opts.max_size {
637        None => &aged,
638        Some(max) => keep_by_size(&aged, max),
639    };
640
641    let bytes_after: u64 = kept.iter().map(|l| l.len() as u64 + 1).sum();
642    let outcome = PruneOutcome {
643        removed: all.len() - kept.len(),
644        kept: kept.len(),
645        bytes_before,
646        bytes_after,
647    };
648
649    if !opts.dry_run && outcome.removed > 0 {
650        rewrite_atomically(path, kept)?;
651    }
652    Ok(outcome)
653}
654
655/// Whether a raw line survives the age filter. Absent filter keeps everything;
656/// an undateable or malformed line is conservatively kept.
657fn keep_by_age(line: &str, older_than: Option<DateTime<Utc>>) -> bool {
658    let Some(cutoff) = older_than else {
659        return true;
660    };
661    match serde_json::from_str::<LogRecord>(line) {
662        Ok(rec) => match DateTime::parse_from_rfc3339(&rec.timestamp) {
663            Ok(ts) => ts.with_timezone(&Utc) >= cutoff,
664            Err(_) => true,
665        },
666        Err(_) => true,
667    }
668}
669
670/// Longest suffix of `lines` whose bytes (each line + its newline) fit in `max`,
671/// but never fewer than the single most recent line.
672fn keep_by_size<'a>(lines: &'a [&'a str], max: u64) -> &'a [&'a str] {
673    let mut acc = 0u64;
674    let mut start = lines.len();
675    for (i, line) in lines.iter().enumerate().rev() {
676        acc += line.len() as u64 + 1;
677        if acc > max {
678            break;
679        }
680        start = i;
681    }
682    if start == lines.len() && !lines.is_empty() {
683        start = lines.len() - 1; // keep at least the most recent record
684    }
685    &lines[start..]
686}
687
688/// Writes `lines` (each newline-terminated) to a same-directory temp file and
689/// atomically renames it over `path`, preserving the `0600` posture on unix.
690fn rewrite_atomically(path: &Path, lines: &[&str]) -> anyhow::Result<()> {
691    let tmp = sibling(path, &format!(".prune.{}.tmp", std::process::id()));
692    let result = (|| -> anyhow::Result<()> {
693        let mut options = std::fs::OpenOptions::new();
694        options.create(true).write(true).truncate(true);
695        #[cfg(unix)]
696        {
697            use std::os::unix::fs::OpenOptionsExt;
698            options.mode(0o600);
699        }
700        let mut file = options.open(&tmp)?;
701        #[cfg(unix)]
702        crate::daemon::paths::ensure_handle_0600(&file)?;
703        for line in lines {
704            file.write_all(line.as_bytes())?;
705            file.write_all(b"\n")?;
706        }
707        file.flush()?;
708        std::fs::rename(&tmp, path)?;
709        Ok(())
710    })();
711    if result.is_err() {
712        let _ = std::fs::remove_file(&tmp);
713    }
714    result
715}
716
717/// The outcome of an invocation, recorded once after `cli.execute()` returns.
718#[derive(Debug, Clone)]
719pub struct InvocationOutcome {
720    /// Resolved clap subcommand path.
721    pub command: Vec<String>,
722    /// Full argv.
723    pub command_line: Vec<String>,
724    /// Process exit code.
725    pub exit_code: i32,
726    /// Rendered error chain, when the command failed.
727    pub error: Option<String>,
728    /// Wall time of the whole invocation.
729    pub duration: Duration,
730}
731
732/// Appends one `kind: "invocation"` record from the active context.
733pub fn record_invocation(outcome: InvocationOutcome) {
734    let ctx = current_context();
735    let mut rec = LogRecord::new(RecordKind::Invocation, ctx.invocation_id);
736    rec.source = Some(ctx.source);
737    rec.mcp_tool = ctx.mcp_tool;
738    rec.command = outcome.command;
739    rec.command_line = scrub_argv(&outcome.command_line);
740    rec.exit_code = Some(outcome.exit_code);
741    rec.error = outcome.error;
742    rec.duration_ms = Some(outcome.duration.as_millis() as u64);
743    rec.env = whitelisted_env();
744    record(&rec);
745}
746
747/// The outcome of one `gh` subprocess invocation, recorded by
748/// `crate::github_metrics::run_gh` after the process exits.
749#[derive(Debug, Clone)]
750pub struct GhOutcome {
751    /// The semantic subcommand label, e.g. `"api graphql"`, `"pr list"`. Split on
752    /// spaces into the record's `command` so `--command`/`command:` queries work.
753    pub label: String,
754    /// Full argv passed to `gh` (without the binary path). Scrubbed before write.
755    pub argv: Vec<String>,
756    /// Process exit code; `None` when the process could not be spawned.
757    pub exit_code: Option<i32>,
758    /// Wall time of the subprocess.
759    pub duration: Duration,
760    /// Spawn/collection error, when the invocation did not complete.
761    pub error: Option<String>,
762}
763
764/// Appends one `kind: "gh"` record from the active context.
765///
766/// Best effort and exit-code-safe: it goes through [`record`], which swallows
767/// every error, so a logging failure can never change a `gh` caller's result.
768pub fn record_gh(outcome: GhOutcome) {
769    record(&build_gh_record(outcome, current_context()));
770}
771
772/// Builds the `kind: "gh"` record for `outcome` under `ctx`. Split out from
773/// [`record_gh`] so the record shape (source stamping, subcommand split, argv
774/// scrubbing) is unit-testable without touching the filesystem or environment.
775fn build_gh_record(outcome: GhOutcome, ctx: RequestLogContext) -> LogRecord {
776    let mut rec = LogRecord::new(RecordKind::Gh, ctx.invocation_id);
777    rec.source = Some(ctx.source);
778    rec.mcp_tool = ctx.mcp_tool;
779    rec.command = outcome
780        .label
781        .split(' ')
782        .filter(|s| !s.is_empty())
783        .map(str::to_string)
784        .collect();
785    // Same scrubbing as invocation records — defense in depth. `gh` manages its
786    // own auth (the token never enters our argv), but any secret-bearing `--flag`
787    // or URL-query value is redacted regardless.
788    rec.command_line = scrub_argv(&outcome.argv);
789    rec.exit_code = outcome.exit_code;
790    rec.error = outcome.error;
791    rec.duration_ms = Some(outcome.duration.as_millis() as u64);
792    rec
793}
794
795/// Optional, non-secret extras for an HTTP record. Bodies/headers are gated and
796/// redacted centrally in [`record_http_with`], so callers may pass them freely.
797#[derive(Debug, Clone, Default)]
798pub struct HttpExtra {
799    /// True when served inside the daemon.
800    pub via_daemon: bool,
801    /// Pooled daemon session id that served the request.
802    pub daemon_session_id: Option<String>,
803    /// Non-secret identity used (never the secret).
804    pub auth_principal: Option<String>,
805    /// Raw request headers (redacted + gated before writing).
806    pub request_headers: BTreeMap<String, String>,
807    /// Raw response headers (redacted + gated before writing).
808    pub response_headers: BTreeMap<String, String>,
809    /// Request body (gated before writing).
810    pub request_body: Option<String>,
811    /// Response body (gated before writing).
812    pub response_body: Option<String>,
813    /// Free-form correlation tags.
814    pub context: BTreeMap<String, String>,
815}
816
817/// Appends one `kind: "http"` record with method/url/status/elapsed/error.
818pub fn record_http(
819    service: &str,
820    method: &str,
821    url: &str,
822    started: Instant,
823    status: Option<u16>,
824    error: Option<&str>,
825) {
826    record_http_with(
827        service,
828        method,
829        url,
830        started,
831        status,
832        error,
833        HttpExtra::default(),
834    );
835}
836
837/// Appends one `kind: "http"` record from a `reqwest` send result, mapping
838/// `Ok` → status code and `Err` → error message.
839///
840/// Collapses the `match result { Ok → status, Err → error }` shape the REST
841/// clients previously each open-coded around [`record_http`] (#1152).
842pub fn record_http_result(
843    service: &str,
844    method: &str,
845    url: &str,
846    started: Instant,
847    result: &reqwest::Result<reqwest::Response>,
848) {
849    match result {
850        Ok(response) => {
851            record_http(
852                service,
853                method,
854                url,
855                started,
856                Some(response.status().as_u16()),
857                None,
858            );
859        }
860        Err(error) => {
861            record_http(
862                service,
863                method,
864                url,
865                started,
866                None,
867                Some(&error.to_string()),
868            );
869        }
870    }
871}
872
873/// Appends one `kind: "http"` record with extra, non-secret fields.
874///
875/// Headers and bodies are dropped unless their opt-in env var is set, headers
876/// are always redacted, and URL query/fragment values under secret-looking
877/// keys are replaced with `REDACTED` (`redact_url`) — so no secret can be
878/// written here under any caller.
879#[allow(clippy::too_many_arguments)]
880pub fn record_http_with(
881    service: &str,
882    method: &str,
883    url: &str,
884    started: Instant,
885    status: Option<u16>,
886    error: Option<&str>,
887    extra: HttpExtra,
888) {
889    if disabled() {
890        return;
891    }
892    let ctx = current_context();
893    let mut rec = LogRecord::new(RecordKind::Http, ctx.invocation_id);
894    rec.source = Some(ctx.source);
895    rec.mcp_tool = ctx.mcp_tool;
896    rec.service = Some(service.to_string());
897    rec.method = Some(method.to_string());
898    rec.url = Some(redact_url(url));
899    rec.status_code = status;
900    rec.elapsed_ms = Some(started.elapsed().as_millis() as u64);
901    rec.error = error.map(str::to_string);
902    rec.via_daemon = extra.via_daemon;
903    rec.daemon_session_id = extra.daemon_session_id;
904    rec.auth_principal = extra.auth_principal;
905    rec.context = extra.context;
906    if headers_enabled() {
907        rec.request_headers = redact_headers(&extra.request_headers);
908        rec.response_headers = redact_headers(&extra.response_headers);
909    }
910    if bodies_enabled() {
911        rec.request_body = extra.request_body;
912        rec.response_body = extra.response_body;
913    }
914    record(&rec);
915}
916
917/// Header names whose values must never be written (compared lowercased).
918const SENSITIVE_HEADERS: &[&str] = &[
919    "authorization",
920    "proxy-authorization",
921    "cookie",
922    "set-cookie",
923    "x-api-key",
924    "api-key",
925    "dd-api-key",
926    "dd-application-key",
927    "x-datadog-api-key",
928    "x-datadog-application-key",
929    "x-omni-bridge",
930    "x-omni-bridge-target",
931];
932
933/// Substrings that mark a header name as secret-bearing (compared lowercased),
934/// guarding against off-list auth headers (e.g. `x-auth-token`,
935/// `x-goog-api-key`). False positives redact harmlessly.
936const SENSITIVE_HEADER_MARKERS: &[&str] = &[
937    "auth",
938    "token",
939    "secret",
940    "key",
941    "cookie",
942    "password",
943    "session",
944    "signature",
945    "credential",
946];
947
948/// Replaces sensitive header values with `REDACTED`, passing others through.
949///
950/// A header is sensitive when its lowercased name is in [`SENSITIVE_HEADERS`]
951/// or contains any [`SENSITIVE_HEADER_MARKERS`] substring.
952pub fn redact_headers(headers: &BTreeMap<String, String>) -> BTreeMap<String, String> {
953    headers
954        .iter()
955        .map(|(name, value)| {
956            let lower = name.to_ascii_lowercase();
957            let redacted = SENSITIVE_HEADERS.contains(&lower.as_str())
958                || SENSITIVE_HEADER_MARKERS
959                    .iter()
960                    .any(|marker| lower.contains(marker));
961            (
962                name.clone(),
963                if redacted {
964                    "REDACTED".to_string()
965                } else {
966                    value.clone()
967                },
968            )
969        })
970        .collect()
971}
972
973/// Flag-name segments marking a long flag's value as secret-bearing — the argv
974/// counterpart of [`SECRETISH`]. Matched per `-`/`_`-separated segment of the
975/// flag name so `--api-key` is caught but a name like `--keyword` is not.
976const SECRETISH_FLAG_WORDS: &[&str] = &["token", "secret", "password", "passwd", "key"];
977
978/// True when the long flag `--<name>` takes a secret-bearing value. Flags whose
979/// last segment is `file` or `path` carry paths, not secrets, and are exempt
980/// (e.g. `--token-file`).
981fn is_secretish_flag(name: &str) -> bool {
982    let segments: Vec<String> = name
983        .split(['-', '_'])
984        .map(str::to_ascii_lowercase)
985        .collect();
986    let takes_path = matches!(segments.last().map(String::as_str), Some("file" | "path"));
987    !takes_path
988        && segments
989            .iter()
990            .any(|segment| SECRETISH_FLAG_WORDS.contains(&segment.as_str()))
991}
992
993/// Scrubs one `--header` value (`Name: Value`): values of [`SENSITIVE_HEADERS`]
994/// are redacted keeping the name, other headers pass through (`None`), and a
995/// value with no colon is redacted wholesale.
996fn scrub_header_arg(value: &str) -> Option<String> {
997    let Some((name, _)) = value.split_once(':') else {
998        return Some("REDACTED".to_string());
999    };
1000    SENSITIVE_HEADERS
1001        .contains(&name.trim().to_ascii_lowercase().as_str())
1002        .then(|| format!("{}: REDACTED", name.trim()))
1003}
1004
1005/// Returns the scrubbed replacement for the value of flag `--<name>`, or
1006/// `None` when the value is safe to log verbatim. `--body` keeps `@file`
1007/// references (a path, not a secret).
1008fn scrub_flag_value(name: &str, value: &str) -> Option<String> {
1009    match name {
1010        "header" => scrub_header_arg(value),
1011        "body" => (!value.starts_with('@')).then(|| "REDACTED".to_string()),
1012        _ if is_secretish_flag(name) => Some("REDACTED".to_string()),
1013        _ => None,
1014    }
1015}
1016
1017/// Scrubs secret-bearing values out of a raw argv before it is logged. Two
1018/// write-side layers, so the on-disk line is clean and every reader/format is
1019/// covered with no reader changes:
1020///
1021/// 1. [`scrub_flag_secrets`] — flag-aware whole-value redaction (`--header`/
1022///    `--body` plus any [`is_secretish_flag`] name, in both `--flag value` and
1023///    `--flag=value` forms).
1024/// 2. [`redact_url`] over every resulting element — a secret-bearing query or
1025///    fragment parameter on a URL argument (most naturally
1026///    `--url /path?access_token=…`, which no flag-name rule catches) has its
1027///    value redacted, while benign argv passes through byte-identical (#1162).
1028fn scrub_argv(argv: &[String]) -> Vec<String> {
1029    scrub_flag_secrets(argv)
1030        .iter()
1031        .map(|arg| redact_url(arg))
1032        .collect()
1033}
1034
1035/// Flag-aware first layer of [`scrub_argv`]: redacts secret-bearing flag values
1036/// (`--header`/`--body` plus any [`is_secretish_flag`] name, in both
1037/// `--flag value` and `--flag=value` forms). Everything else passes through to
1038/// the URL layer.
1039fn scrub_flag_secrets(argv: &[String]) -> Vec<String> {
1040    let mut out = Vec::with_capacity(argv.len());
1041    let mut i = 0;
1042    while i < argv.len() {
1043        let arg = &argv[i];
1044        i += 1;
1045        let Some(flag_body) = arg.strip_prefix("--") else {
1046            out.push(arg.clone());
1047            continue;
1048        };
1049        if let Some((name, value)) = flag_body.split_once('=') {
1050            match scrub_flag_value(name, value) {
1051                Some(scrubbed) => out.push(format!("--{name}={scrubbed}")),
1052                None => out.push(arg.clone()),
1053            }
1054        } else {
1055            out.push(arg.clone());
1056            let takes_secret_value =
1057                matches!(flag_body, "header" | "body") || is_secretish_flag(flag_body);
1058            if takes_secret_value {
1059                if let Some(value) = argv.get(i) {
1060                    i += 1;
1061                    out.push(scrub_flag_value(flag_body, value).unwrap_or_else(|| value.clone()));
1062                }
1063            }
1064        }
1065    }
1066    out
1067}
1068
1069/// Query/fragment keys that are secrets outright (compared decoded + lowercased).
1070const SENSITIVE_QUERY_KEYS: &[&str] = &["sig", "sas", "jwt", "auth"];
1071
1072/// Key suffixes marking the open-ended secret families (`access_token`,
1073/// `client_secret`, `api_key`, …).
1074const SENSITIVE_QUERY_KEY_SUFFIXES: &[&str] = &[
1075    "token",
1076    "secret",
1077    "password",
1078    "passwd",
1079    "signature",
1080    "apikey",
1081    "api_key",
1082    "api-key",
1083];
1084
1085/// Key prefixes for cloud-storage signed-URL parameter families.
1086const SENSITIVE_QUERY_KEY_PREFIXES: &[&str] = &["x-amz-", "x-goog-"];
1087
1088/// Returns whether a decoded query/fragment key looks secret-bearing.
1089fn sensitive_query_key(key: &str) -> bool {
1090    let key = key.to_ascii_lowercase();
1091    SENSITIVE_QUERY_KEYS.contains(&key.as_str())
1092        || SENSITIVE_QUERY_KEY_SUFFIXES
1093            .iter()
1094            .any(|suffix| key.ends_with(suffix))
1095        || SENSITIVE_QUERY_KEY_PREFIXES
1096            .iter()
1097            .any(|prefix| key.starts_with(prefix))
1098}
1099
1100/// Rewrites one `&`-separated pair list, replacing the values of
1101/// secret-bearing keys with `REDACTED` and passing every other segment
1102/// through byte-verbatim.
1103fn redact_pairs(pairs: &str) -> String {
1104    pairs
1105        .split('&')
1106        .map(|segment| match segment.split_once('=') {
1107            Some((raw_key, _)) => {
1108                // Decode only the key (handles `access%5Ftoken` and `+`); the
1109                // raw key text is preserved in the output.
1110                let sensitive = url::form_urlencoded::parse(raw_key.as_bytes())
1111                    .next()
1112                    .is_some_and(|(key, _)| sensitive_query_key(&key));
1113                if sensitive {
1114                    format!("{raw_key}=REDACTED")
1115                } else {
1116                    segment.to_string()
1117                }
1118            }
1119            // A bare key (no `=`) carries no value to leak.
1120            None => segment.to_string(),
1121        })
1122        .collect::<Vec<_>>()
1123        .join("&")
1124}
1125
1126/// Redacts secret-bearing query and fragment parameter values in a URL,
1127/// preserving scheme, host, path, and all parameter keys so `--url` substring
1128/// filtering stays useful. Handles relative URLs (the browser bridge logs
1129/// page-origin targets like `/api/foo?sig=…`), so this never requires the
1130/// input to parse as an absolute [`url::Url`].
1131fn redact_url(url: &str) -> String {
1132    let (rest, fragment) = url
1133        .split_once('#')
1134        .map_or((url, None), |(rest, fragment)| (rest, Some(fragment)));
1135    let (prefix, query) = rest
1136        .split_once('?')
1137        .map_or((rest, None), |(prefix, query)| (prefix, Some(query)));
1138    let mut out = prefix.to_string();
1139    if let Some(query) = query {
1140        out.push('?');
1141        out.push_str(&redact_pairs(query));
1142    }
1143    if let Some(fragment) = fragment {
1144        out.push('#');
1145        out.push_str(&redact_pairs(fragment));
1146    }
1147    out
1148}
1149
1150/// A time-sortable id: 13-digit zero-padded epoch-millis, a dash, then 16 hex.
1151///
1152/// Lexical order ≈ chronological order, which is all the reader needs. Mirrors
1153/// the uuid-shaped minting in [`crate::snowflake::client`] without adding a
1154/// crate.
1155pub fn new_id() -> String {
1156    let millis = chrono::Utc::now().timestamp_millis().max(0);
1157    let suffix = rand::random::<u64>();
1158    format!("{millis:013}-{suffix:016x}")
1159}
1160
1161/// Current time as RFC3339 with millisecond precision, in UTC.
1162fn now_rfc3339_millis() -> String {
1163    chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
1164}
1165
1166/// Best-effort current working directory.
1167fn cwd() -> String {
1168    std::env::current_dir()
1169        .map(|p| p.display().to_string())
1170        .unwrap_or_default()
1171}
1172
1173/// Best-effort OS username (`$USER`, then the passwd entry for the euid).
1174fn system_user() -> String {
1175    if let Ok(user) = std::env::var("USER") {
1176        if !user.is_empty() {
1177            return user;
1178        }
1179    }
1180    #[cfg(unix)]
1181    {
1182        if let Ok(Some(user)) = nix::unistd::User::from_uid(nix::unistd::geteuid()) {
1183            return user.name;
1184        }
1185    }
1186    String::new()
1187}
1188
1189/// Best-effort hostname (`gethostname`, then `$HOSTNAME`, then empty).
1190fn hostname() -> String {
1191    #[cfg(unix)]
1192    {
1193        if let Ok(name) = nix::unistd::gethostname() {
1194            if let Some(name) = name.to_str() {
1195                if !name.is_empty() {
1196                    return name.to_string();
1197                }
1198            }
1199        }
1200    }
1201    std::env::var("HOSTNAME").unwrap_or_default()
1202}
1203
1204/// Names matching these substrings have their env values redacted, guarding
1205/// against any future secret-bearing `OMNI_DEV_*` var.
1206const SECRETISH: &[&str] = &["TOKEN", "SECRET", "KEY", "PASSWORD", "PASSWD"];
1207
1208/// Snapshot of `OMNI_DEV_*` env vars, with secret-looking values redacted.
1209fn whitelisted_env() -> BTreeMap<String, String> {
1210    std::env::vars()
1211        .filter(|(k, _)| k.starts_with("OMNI_DEV_"))
1212        .map(|(k, v)| {
1213            let secretish = SECRETISH.iter().any(|needle| k.contains(needle));
1214            let value = if secretish { "REDACTED".to_string() } else { v };
1215            (k, value)
1216        })
1217        .collect()
1218}
1219
1220#[cfg(test)]
1221#[allow(clippy::unwrap_used, clippy::expect_used)]
1222mod tests {
1223    use super::*;
1224
1225    #[test]
1226    fn record_round_trips_through_json() {
1227        let mut rec = LogRecord::new(RecordKind::Http, "inv-1".to_string());
1228        rec.service = Some("jira".to_string());
1229        rec.method = Some("GET".to_string());
1230        rec.url = Some("https://example.atlassian.net/rest/api/3/issue/X-1".to_string());
1231        rec.status_code = Some(200);
1232        rec.elapsed_ms = Some(42);
1233
1234        let line = serde_json::to_string(&rec).unwrap();
1235        let back: LogRecord = serde_json::from_str(&line).unwrap();
1236        assert_eq!(back.invocation_id, "inv-1");
1237        assert_eq!(back.kind, RecordKind::Http);
1238        assert_eq!(back.service.as_deref(), Some("jira"));
1239        assert_eq!(back.status_code, Some(200));
1240    }
1241
1242    #[test]
1243    fn reader_tolerates_unknown_fields() {
1244        let line = r#"{"id":"x","invocation_id":"i","kind":"http","method":"GET",
1245            "future_field":{"nested":true},"another":42}"#;
1246        let rec: LogRecord = serde_json::from_str(line).unwrap();
1247        assert_eq!(rec.kind, RecordKind::Http);
1248        assert_eq!(rec.method.as_deref(), Some("GET"));
1249    }
1250
1251    #[test]
1252    fn reader_tolerates_missing_newer_fields() {
1253        // An "old" line with only a couple of fields present.
1254        let line = r#"{"kind":"invocation","command":["git","view"]}"#;
1255        let rec: LogRecord = serde_json::from_str(line).unwrap();
1256        assert_eq!(rec.kind, RecordKind::Invocation);
1257        assert_eq!(rec.command, vec!["git", "view"]);
1258        assert!(rec.status_code.is_none());
1259        assert!(rec.id.is_empty());
1260    }
1261
1262    #[test]
1263    fn unknown_kind_and_source_do_not_fail() {
1264        let line = r#"{"kind":"telemetry","source":"webhook"}"#;
1265        let rec: LogRecord = serde_json::from_str(line).unwrap();
1266        assert_eq!(rec.kind, RecordKind::Unknown);
1267        assert_eq!(rec.source, Some(Source::Unknown));
1268    }
1269
1270    #[test]
1271    fn optional_fields_are_skipped_when_empty() {
1272        let rec = LogRecord::new(RecordKind::Invocation, "i".to_string());
1273        let line = serde_json::to_string(&rec).unwrap();
1274        // Empty collections / None options must not appear on the wire.
1275        assert!(!line.contains("status_code"));
1276        assert!(!line.contains("request_headers"));
1277        assert!(!line.contains("via_daemon"));
1278        assert!(!line.contains("\"env\""));
1279    }
1280
1281    #[test]
1282    fn ids_are_time_sortable() {
1283        let a = new_id();
1284        std::thread::sleep(std::time::Duration::from_millis(2));
1285        let b = new_id();
1286        assert!(a < b, "{a} should sort before {b}");
1287    }
1288
1289    #[test]
1290    fn sensitive_headers_are_redacted() {
1291        let mut headers = BTreeMap::new();
1292        headers.insert("Authorization".to_string(), "Bearer secret".to_string());
1293        headers.insert("X-Api-Key".to_string(), "abc123".to_string());
1294        headers.insert("Content-Type".to_string(), "application/json".to_string());
1295        let out = redact_headers(&headers);
1296        assert_eq!(out["Authorization"], "REDACTED");
1297        assert_eq!(out["X-Api-Key"], "REDACTED");
1298        assert_eq!(out["Content-Type"], "application/json");
1299    }
1300
1301    fn argv(args: &[&str]) -> Vec<String> {
1302        args.iter().copied().map(String::from).collect()
1303    }
1304
1305    #[test]
1306    fn build_gh_record_stamps_kind_source_and_split_command() {
1307        let ctx = RequestLogContext {
1308            invocation_id: "inv-1".to_string(),
1309            source: Source::Daemon,
1310            mcp_tool: None,
1311        };
1312        let rec = build_gh_record(
1313            GhOutcome {
1314                label: "api graphql".to_string(),
1315                argv: argv(&["api", "graphql", "-f", "query=xyz"]),
1316                exit_code: Some(0),
1317                duration: Duration::from_millis(120),
1318                error: None,
1319            },
1320            ctx,
1321        );
1322        assert_eq!(rec.kind, RecordKind::Gh);
1323        assert_eq!(rec.invocation_id, "inv-1");
1324        assert_eq!(rec.source, Some(Source::Daemon));
1325        // The label is split into `command` for per-subcommand aggregation.
1326        assert_eq!(rec.command, argv(&["api", "graphql"]));
1327        assert_eq!(
1328            rec.command_line,
1329            argv(&["api", "graphql", "-f", "query=xyz"])
1330        );
1331        assert_eq!(rec.exit_code, Some(0));
1332        assert_eq!(rec.duration_ms, Some(120));
1333        assert!(rec.error.is_none());
1334    }
1335
1336    #[test]
1337    fn build_gh_record_scrubs_secret_bearing_argv() {
1338        // Defense in depth: even though `gh` never receives our auth, a
1339        // secret-bearing flag value in the argv is redacted before write.
1340        let rec = build_gh_record(
1341            GhOutcome {
1342                label: "api graphql".to_string(),
1343                argv: argv(&["api", "--header", "Authorization: Bearer sekret"]),
1344                exit_code: Some(0),
1345                duration: Duration::from_millis(5),
1346                error: None,
1347            },
1348            RequestLogContext::default(),
1349        );
1350        assert_eq!(
1351            rec.command_line,
1352            argv(&["api", "--header", "Authorization: REDACTED"])
1353        );
1354    }
1355
1356    #[test]
1357    fn record_kind_gh_serializes_as_gh_and_round_trips() {
1358        let rec = build_gh_record(
1359            GhOutcome {
1360                label: "pr list".to_string(),
1361                argv: argv(&["pr", "list"]),
1362                exit_code: Some(1),
1363                duration: Duration::from_millis(1),
1364                error: Some("boom".to_string()),
1365            },
1366            RequestLogContext::default(),
1367        );
1368        let line = serde_json::to_string(&rec).unwrap();
1369        assert!(line.contains("\"kind\":\"gh\""), "line was: {line}");
1370        let back: LogRecord = serde_json::from_str(&line).unwrap();
1371        assert_eq!(back.kind, RecordKind::Gh);
1372        assert_eq!(back.command, argv(&["pr", "list"]));
1373        assert_eq!(back.error.as_deref(), Some("boom"));
1374    }
1375
1376    #[test]
1377    fn scrub_argv_redacts_sensitive_header_in_both_forms() {
1378        let out = scrub_argv(&argv(&[
1379            "omni-dev",
1380            "--header",
1381            "Authorization: Bearer sekret",
1382            "--header=Cookie: session=abc",
1383        ]));
1384        assert_eq!(
1385            out,
1386            argv(&[
1387                "omni-dev",
1388                "--header",
1389                "Authorization: REDACTED",
1390                "--header=Cookie: REDACTED",
1391            ])
1392        );
1393    }
1394
1395    #[test]
1396    fn scrub_argv_keeps_non_sensitive_headers() {
1397        let input = argv(&["omni-dev", "--header", "Content-Type: application/json"]);
1398        assert_eq!(scrub_argv(&input), input);
1399    }
1400
1401    #[test]
1402    fn scrub_argv_redacts_colonless_header_wholesale() {
1403        let out = scrub_argv(&argv(&["omni-dev", "--header", "sekret"]));
1404        assert_eq!(out, argv(&["omni-dev", "--header", "REDACTED"]));
1405    }
1406
1407    #[test]
1408    fn scrub_argv_redacts_inline_body_but_keeps_at_file() {
1409        let out = scrub_argv(&argv(&["omni-dev", "--body", r#"{"secret":1}"#]));
1410        assert_eq!(out, argv(&["omni-dev", "--body", "REDACTED"]));
1411
1412        let file_form = argv(&["omni-dev", "--body", "@payload.json"]);
1413        assert_eq!(scrub_argv(&file_form), file_form);
1414
1415        let out = scrub_argv(&argv(&["omni-dev", "--body=sekret"]));
1416        assert_eq!(out, argv(&["omni-dev", "--body=REDACTED"]));
1417    }
1418
1419    #[test]
1420    fn scrub_argv_redacts_secretish_flag_values() {
1421        let out = scrub_argv(&argv(&["omni-dev", "--api-key", "abc", "--auth-token=xyz"]));
1422        assert_eq!(
1423            out,
1424            argv(&["omni-dev", "--api-key", "REDACTED", "--auth-token=REDACTED"])
1425        );
1426    }
1427
1428    #[test]
1429    fn scrub_argv_exempts_path_flags_and_positionals() {
1430        let input = argv(&["omni-dev", "--token-file", "/tmp/t", "PROJ-123"]);
1431        assert_eq!(scrub_argv(&input), input);
1432    }
1433
1434    #[test]
1435    fn scrub_argv_redacts_secret_bearing_url_query_in_both_forms() {
1436        // `--url` is not a secret-ish flag name, so its value is caught by the
1437        // redact_url layer, not the flag layer (#1162). Both argv shapes plus a
1438        // bare positional URL are covered; the benign `page` param survives.
1439        let space = scrub_argv(&argv(&[
1440            "omni-dev",
1441            "browser",
1442            "bridge",
1443            "request",
1444            "--url",
1445            "/api/export?access_token=hunter2&sig=deadbeef&page=3",
1446        ]));
1447        assert_eq!(
1448            *space.last().unwrap(),
1449            "/api/export?access_token=REDACTED&sig=REDACTED&page=3"
1450        );
1451
1452        let eq_form = scrub_argv(&argv(&[
1453            "omni-dev",
1454            "--url=/api/export?access_token=hunter2&page=3",
1455        ]));
1456        assert_eq!(
1457            *eq_form.last().unwrap(),
1458            "--url=/api/export?access_token=REDACTED&page=3"
1459        );
1460
1461        let positional = scrub_argv(&argv(&["omni-dev", "https://h/cb#id_token=xyz"]));
1462        assert_eq!(
1463            *positional.last().unwrap(),
1464            "https://h/cb#id_token=REDACTED"
1465        );
1466    }
1467
1468    #[test]
1469    fn scrub_argv_leaves_benign_argv_byte_identical() {
1470        let input = argv(&[
1471            "omni-dev",
1472            "browser",
1473            "bridge",
1474            "request",
1475            "--control-port",
1476            "19998",
1477            "--url",
1478            "/api/export?page=3&sort=asc",
1479        ]);
1480        assert_eq!(scrub_argv(&input), input);
1481    }
1482
1483    #[test]
1484    fn scrub_argv_handles_trailing_flag_without_value() {
1485        let input = argv(&["omni-dev", "--body"]);
1486        assert_eq!(scrub_argv(&input), input);
1487    }
1488
1489    #[cfg(unix)]
1490    #[test]
1491    fn append_line_creates_file_owner_only() {
1492        use std::os::unix::fs::PermissionsExt;
1493        let dir = tempfile::tempdir().unwrap();
1494        let path = dir.path().join("log.jsonl");
1495        append_line(&path, "{\"kind\":\"http\"}\n").unwrap();
1496        assert_eq!(
1497            std::fs::read_to_string(&path).unwrap(),
1498            "{\"kind\":\"http\"}\n"
1499        );
1500        assert_eq!(
1501            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1502            0o600
1503        );
1504    }
1505
1506    #[cfg(unix)]
1507    #[test]
1508    fn append_line_retightens_preexisting_loose_file() {
1509        use std::os::unix::fs::PermissionsExt;
1510        let dir = tempfile::tempdir().unwrap();
1511        let path = dir.path().join("log.jsonl");
1512        std::fs::write(&path, "old\n").unwrap();
1513        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1514        append_line(&path, "new\n").unwrap();
1515        assert_eq!(std::fs::read_to_string(&path).unwrap(), "old\nnew\n");
1516        assert_eq!(
1517            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1518            0o600
1519        );
1520    }
1521
1522    #[test]
1523    fn off_list_secretish_headers_are_redacted() {
1524        let mut headers = BTreeMap::new();
1525        for name in [
1526            "X-Auth-Token",
1527            "x-amz-security-token",
1528            "X-Goog-Api-Key",
1529            "x-csrf-token",
1530            "X-Vendor-Token",
1531            "X-Omni-Bridge",
1532        ] {
1533            headers.insert(name.to_string(), "secret-value".to_string());
1534        }
1535        for name in [
1536            "Content-Type",
1537            "Accept",
1538            "User-Agent",
1539            "x-request-id",
1540            "traceparent",
1541        ] {
1542            headers.insert(name.to_string(), "plain-value".to_string());
1543        }
1544        let out = redact_headers(&headers);
1545        assert_eq!(out["X-Auth-Token"], "REDACTED");
1546        assert_eq!(out["x-amz-security-token"], "REDACTED");
1547        assert_eq!(out["X-Goog-Api-Key"], "REDACTED");
1548        assert_eq!(out["x-csrf-token"], "REDACTED");
1549        assert_eq!(out["X-Vendor-Token"], "REDACTED");
1550        assert_eq!(out["X-Omni-Bridge"], "REDACTED");
1551        assert_eq!(out["Content-Type"], "plain-value");
1552        assert_eq!(out["Accept"], "plain-value");
1553        assert_eq!(out["User-Agent"], "plain-value");
1554        assert_eq!(out["x-request-id"], "plain-value");
1555        assert_eq!(out["traceparent"], "plain-value");
1556    }
1557
1558    #[test]
1559    fn url_without_query_is_unchanged() {
1560        assert_eq!(redact_url("https://h/p"), "https://h/p");
1561        assert_eq!(redact_url("/relative/p"), "/relative/p");
1562    }
1563
1564    #[test]
1565    fn benign_query_is_byte_identical() {
1566        let url = "https://h/p?q=a%20b&page=2&&x=y+z&keyword=k&sort_key=s&token_type=bearer";
1567        assert_eq!(redact_url(url), url);
1568    }
1569
1570    #[test]
1571    fn sensitive_query_values_are_redacted() {
1572        let url = "https://h/p?token=a&access_token=b&client_secret=c&api_key=d&x=1";
1573        assert_eq!(
1574            redact_url(url),
1575            "https://h/p?token=REDACTED&access_token=REDACTED&client_secret=REDACTED\
1576             &api_key=REDACTED&x=1"
1577        );
1578    }
1579
1580    #[test]
1581    fn presigned_s3_query_is_redacted() {
1582        let url = "https://bucket.s3.amazonaws.com/key?X-Amz-Algorithm=AWS4-HMAC-SHA256\
1583                   &X-Amz-Credential=AKIA%2F20260703%2Fus-east-1%2Fs3%2Faws4_request\
1584                   &X-Amz-Date=20260703T000000Z&X-Amz-Expires=3600\
1585                   &X-Amz-SignedHeaders=host&X-Amz-Signature=deadbeef";
1586        assert_eq!(
1587            redact_url(url),
1588            "https://bucket.s3.amazonaws.com/key?X-Amz-Algorithm=REDACTED\
1589             &X-Amz-Credential=REDACTED&X-Amz-Date=REDACTED&X-Amz-Expires=REDACTED\
1590             &X-Amz-SignedHeaders=REDACTED&X-Amz-Signature=REDACTED"
1591        );
1592    }
1593
1594    #[test]
1595    fn key_matching_is_case_insensitive() {
1596        assert_eq!(
1597            redact_url("/p?TOKEN=x&Api_Key=y&X-Amz-Signature=z"),
1598            "/p?TOKEN=REDACTED&Api_Key=REDACTED&X-Amz-Signature=REDACTED"
1599        );
1600    }
1601
1602    #[test]
1603    fn repeated_sensitive_keys_are_each_redacted() {
1604        assert_eq!(redact_url("/p?sig=a&sig=b"), "/p?sig=REDACTED&sig=REDACTED");
1605    }
1606
1607    #[test]
1608    fn valueless_key_is_left_alone() {
1609        assert_eq!(redact_url("/p?token"), "/p?token");
1610        assert_eq!(redact_url("/p?token="), "/p?token=REDACTED");
1611    }
1612
1613    #[test]
1614    fn relative_url_query_is_redacted() {
1615        assert_eq!(
1616            redact_url("/api/foo?sig=abc&x=y"),
1617            "/api/foo?sig=REDACTED&x=y"
1618        );
1619    }
1620
1621    #[test]
1622    fn fragment_credentials_are_redacted() {
1623        assert_eq!(
1624            redact_url("https://h/cb#access_token=xyz&token_type=bearer"),
1625            "https://h/cb#access_token=REDACTED&token_type=bearer"
1626        );
1627    }
1628
1629    #[test]
1630    fn query_and_fragment_are_scrubbed_independently() {
1631        assert_eq!(
1632            redact_url("/p?sig=a#id_token=b"),
1633            "/p?sig=REDACTED#id_token=REDACTED"
1634        );
1635    }
1636
1637    #[test]
1638    fn question_mark_in_fragment_is_not_parsed_as_query() {
1639        // The fragment is split off before the query, so `?` inside it never
1640        // starts a query; the pseudo-key `frag?token` still redacts via the
1641        // suffix rule (over-redaction in the safe direction).
1642        assert_eq!(
1643            redact_url("https://h/p#frag?token=x"),
1644            "https://h/p#frag?token=REDACTED"
1645        );
1646    }
1647
1648    #[test]
1649    fn encoded_sensitive_key_is_decoded_before_matching() {
1650        assert_eq!(
1651            redact_url("/p?access%5Ftoken=v"),
1652            "/p?access%5Ftoken=REDACTED"
1653        );
1654    }
1655
1656    #[test]
1657    fn empty_query_is_unchanged() {
1658        assert_eq!(redact_url("https://h/p?"), "https://h/p?");
1659        assert_eq!(redact_url("https://h/p?#f"), "https://h/p?#f");
1660    }
1661
1662    #[test]
1663    fn env_flag_parses_truthy_values() {
1664        std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "1");
1665        assert!(env_flag("OMNI_DEV_TEST_FLAG_ABC"));
1666        std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "TRUE");
1667        assert!(env_flag("OMNI_DEV_TEST_FLAG_ABC"));
1668        std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "0");
1669        assert!(!env_flag("OMNI_DEV_TEST_FLAG_ABC"));
1670        std::env::remove_var("OMNI_DEV_TEST_FLAG_ABC");
1671        assert!(!env_flag("OMNI_DEV_TEST_FLAG_ABC"));
1672    }
1673
1674    #[test]
1675    fn parse_size_handles_units_and_bare_bytes() {
1676        assert_eq!(parse_size("1048576").unwrap(), 1024 * 1024);
1677        assert_eq!(parse_size("512b").unwrap(), 512);
1678        assert_eq!(parse_size("10kb").unwrap(), 10 * 1024);
1679        assert_eq!(parse_size("2K").unwrap(), 2 * 1024);
1680        assert_eq!(parse_size("3mb").unwrap(), 3 * 1024 * 1024);
1681        assert_eq!(parse_size("1gb").unwrap(), 1024 * 1024 * 1024);
1682        assert_eq!(parse_size("1.5mb").unwrap(), (1.5 * 1024.0 * 1024.0) as u64);
1683        assert_eq!(parse_size(" 4mib ").unwrap(), 4 * 1024 * 1024);
1684    }
1685
1686    #[test]
1687    fn parse_size_rejects_garbage() {
1688        assert!(parse_size("").is_err());
1689        assert!(parse_size("mb").is_err());
1690        assert!(parse_size("10tb").is_err());
1691        assert!(parse_size("-5mb").is_err());
1692    }
1693
1694    #[test]
1695    fn sibling_appends_to_final_component() {
1696        let base = Path::new("/tmp/omni/log.jsonl");
1697        assert_eq!(sibling(base, ".1"), Path::new("/tmp/omni/log.jsonl.1"));
1698        assert_eq!(
1699            sibling(base, ".lock"),
1700            Path::new("/tmp/omni/log.jsonl.lock")
1701        );
1702    }
1703
1704    #[test]
1705    fn keep_by_size_keeps_most_recent_that_fit() {
1706        // Four 10-byte lines (11 bytes on disk each with the newline).
1707        let lines = ["aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc", "dddddddddd"];
1708        let refs: Vec<&str> = lines.to_vec();
1709
1710        // Budget for exactly two lines (22 bytes) keeps the last two.
1711        assert_eq!(keep_by_size(&refs, 22), &["cccccccccc", "dddddddddd"]);
1712        // A budget smaller than one line still keeps the single most recent.
1713        assert_eq!(keep_by_size(&refs, 1), &["dddddddddd"]);
1714        // A generous budget keeps everything.
1715        assert_eq!(keep_by_size(&refs, 10_000), &refs[..]);
1716        // Empty input yields empty output (no panic).
1717        assert!(keep_by_size(&[], 100).is_empty());
1718    }
1719
1720    #[test]
1721    fn keep_by_age_is_conservative_on_undateable_lines() {
1722        let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
1723            .unwrap()
1724            .with_timezone(&Utc);
1725        let old = r#"{"kind":"http","timestamp":"2026-01-01T00:00:00.000Z"}"#;
1726        let new = r#"{"kind":"http","timestamp":"2026-12-01T00:00:00.000Z"}"#;
1727        let undated = r#"{"kind":"http"}"#;
1728        let malformed = "not json at all";
1729
1730        assert!(!keep_by_age(old, Some(cutoff)));
1731        assert!(keep_by_age(new, Some(cutoff)));
1732        assert!(keep_by_age(undated, Some(cutoff)), "undated is kept");
1733        assert!(keep_by_age(malformed, Some(cutoff)), "malformed is kept");
1734        assert!(keep_by_age(old, None), "no filter keeps everything");
1735    }
1736
1737    fn http_line(id: &str, ts: &str) -> String {
1738        format!(r#"{{"id":"{id}","kind":"http","timestamp":"{ts}"}}"#)
1739    }
1740
1741    #[test]
1742    fn prune_by_age_drops_old_records_and_rewrites_atomically() {
1743        use std::os::unix::fs::PermissionsExt;
1744
1745        let dir = tempfile::tempdir().unwrap();
1746        let path = dir.path().join("log.jsonl");
1747        let body = format!(
1748            "{}\n{}\n{}\n",
1749            http_line("1", "2026-01-01T00:00:00.000Z"),
1750            http_line("2", "2026-06-15T00:00:00.000Z"),
1751            http_line("3", "2026-12-31T00:00:00.000Z"),
1752        );
1753        std::fs::write(&path, &body).unwrap();
1754        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1755
1756        let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
1757            .unwrap()
1758            .with_timezone(&Utc);
1759        let outcome = prune(
1760            &path,
1761            &PruneOptions {
1762                older_than: Some(cutoff),
1763                max_size: None,
1764                dry_run: false,
1765            },
1766        )
1767        .unwrap();
1768
1769        assert_eq!(outcome.removed, 1);
1770        assert_eq!(outcome.kept, 2);
1771        let contents = std::fs::read_to_string(&path).unwrap();
1772        assert!(!contents.contains(r#""id":"1""#));
1773        assert!(contents.contains(r#""id":"2""#));
1774        assert!(contents.contains(r#""id":"3""#));
1775        // The atomic rewrite lands a fresh 0600 file regardless of the old mode.
1776        assert_eq!(
1777            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1778            0o600
1779        );
1780    }
1781
1782    #[test]
1783    fn prune_dry_run_reports_without_modifying() {
1784        let dir = tempfile::tempdir().unwrap();
1785        let path = dir.path().join("log.jsonl");
1786        let body = format!(
1787            "{}\n{}\n",
1788            http_line("1", "2026-01-01T00:00:00.000Z"),
1789            http_line("2", "2026-12-31T00:00:00.000Z"),
1790        );
1791        std::fs::write(&path, &body).unwrap();
1792
1793        let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
1794            .unwrap()
1795            .with_timezone(&Utc);
1796        let outcome = prune(
1797            &path,
1798            &PruneOptions {
1799                older_than: Some(cutoff),
1800                max_size: None,
1801                dry_run: true,
1802            },
1803        )
1804        .unwrap();
1805
1806        assert_eq!(outcome.removed, 1);
1807        // File is untouched by a dry run.
1808        assert_eq!(std::fs::read_to_string(&path).unwrap(), body);
1809    }
1810
1811    #[test]
1812    fn prune_by_size_keeps_the_newest_that_fit() {
1813        let dir = tempfile::tempdir().unwrap();
1814        let path = dir.path().join("log.jsonl");
1815        let l1 = http_line("1", "2026-01-01T00:00:00.000Z");
1816        let l2 = http_line("2", "2026-06-15T00:00:00.000Z");
1817        let l3 = http_line("3", "2026-12-31T00:00:00.000Z");
1818        std::fs::write(&path, format!("{l1}\n{l2}\n{l3}\n")).unwrap();
1819
1820        // Budget that fits only the last two lines.
1821        let budget = (l2.len() + 1 + l3.len() + 1) as u64;
1822        let outcome = prune(
1823            &path,
1824            &PruneOptions {
1825                older_than: None,
1826                max_size: Some(budget),
1827                dry_run: false,
1828            },
1829        )
1830        .unwrap();
1831
1832        assert_eq!(outcome.removed, 1);
1833        assert_eq!(outcome.kept, 2);
1834        let contents = std::fs::read_to_string(&path).unwrap();
1835        assert!(!contents.contains(r#""id":"1""#));
1836        assert!(contents.contains(r#""id":"3""#));
1837    }
1838
1839    #[test]
1840    fn prune_missing_file_is_a_noop() {
1841        let dir = tempfile::tempdir().unwrap();
1842        let path = dir.path().join("absent.jsonl");
1843        let outcome = prune(
1844            &path,
1845            &PruneOptions {
1846                older_than: None,
1847                max_size: Some(1),
1848                dry_run: false,
1849            },
1850        )
1851        .unwrap();
1852        assert_eq!(outcome.removed, 0);
1853        assert_eq!(outcome.kept, 0);
1854        assert!(!path.exists());
1855    }
1856
1857    #[cfg(unix)]
1858    #[test]
1859    fn rotation_shifts_numbered_files_and_drops_the_oldest() {
1860        use std::os::unix::fs::PermissionsExt;
1861
1862        let dir = tempfile::tempdir().unwrap();
1863        let path = dir.path().join("log.jsonl");
1864        // A tiny cap so every second short line rotates.
1865        let cfg = RotationConfig {
1866            max_size: 20,
1867            keep_files: 2,
1868        };
1869
1870        let line = "0123456789012345\n"; // 17 bytes
1871        for _ in 0..4 {
1872            append_with_rotation(&path, line, &cfg).unwrap();
1873        }
1874
1875        // The live file plus at most keep_files (2) rotated files exist; a .3
1876        // must never appear.
1877        assert!(path.exists());
1878        assert!(sibling(&path, ".1").exists());
1879        assert!(sibling(&path, ".2").exists());
1880        assert!(!sibling(&path, ".3").exists());
1881        // Rotated files keep the 0600 posture.
1882        assert_eq!(
1883            std::fs::metadata(sibling(&path, ".1"))
1884                .unwrap()
1885                .permissions()
1886                .mode()
1887                & 0o777,
1888            0o600
1889        );
1890    }
1891
1892    #[cfg(unix)]
1893    #[test]
1894    fn rotation_keep_zero_discards_on_overflow() {
1895        let dir = tempfile::tempdir().unwrap();
1896        let path = dir.path().join("log.jsonl");
1897        let cfg = RotationConfig {
1898            max_size: 20,
1899            keep_files: 0,
1900        };
1901        let line = "0123456789012345\n"; // 17 bytes
1902        append_with_rotation(&path, line, &cfg).unwrap();
1903        append_with_rotation(&path, line, &cfg).unwrap();
1904        // No .1 is retained; only the current (single-line) file survives.
1905        assert!(!sibling(&path, ".1").exists());
1906        assert_eq!(std::fs::read_to_string(&path).unwrap(), line);
1907    }
1908
1909    #[test]
1910    fn parse_size_rejects_overflow_to_infinity() {
1911        // A number too large for f64 parses to a non-finite value, not a size.
1912        assert!(parse_size(&"9".repeat(400)).is_err());
1913    }
1914
1915    #[test]
1916    fn prune_surfaces_a_read_error() {
1917        // Reading a directory as the log yields an error other than NotFound,
1918        // which prune propagates rather than treating as an empty log.
1919        let dir = tempfile::tempdir().unwrap();
1920        let result = prune(
1921            dir.path(),
1922            &PruneOptions {
1923                older_than: None,
1924                max_size: Some(1),
1925                dry_run: false,
1926            },
1927        );
1928        assert!(result.is_err());
1929    }
1930
1931    #[cfg(unix)]
1932    #[test]
1933    fn append_with_rotation_appends_even_when_rotate_fails() {
1934        let dir = tempfile::tempdir().unwrap();
1935        let path = dir.path().join("log.jsonl");
1936        // Seed a file already over the cap so the next write attempts to rotate.
1937        std::fs::write(&path, "0123456789012345\n").unwrap();
1938        // Make the rotation target a directory so `rename(log, log.1)` fails.
1939        std::fs::create_dir(sibling(&path, ".1")).unwrap();
1940        let cfg = RotationConfig {
1941            max_size: 5,
1942            keep_files: 1,
1943        };
1944        // Rotation fails, but the line is still appended (best effort).
1945        append_with_rotation(&path, "new-line\n", &cfg).unwrap();
1946        assert!(
1947            std::fs::read_to_string(&path).unwrap().contains("new-line"),
1948            "the record is appended despite the rotation failure"
1949        );
1950    }
1951
1952    #[test]
1953    fn prune_cleans_up_temp_on_rewrite_failure() {
1954        let dir = tempfile::tempdir().unwrap();
1955        let path = dir.path().join("log.jsonl");
1956        std::fs::write(
1957            &path,
1958            format!(
1959                "{}\n{}\n",
1960                http_line("a", "2999-01-01T00:00:00.000Z"),
1961                http_line("b", "2999-01-01T00:00:00.000Z"),
1962            ),
1963        )
1964        .unwrap();
1965        // Pre-create the exact temp path (same-process pid) as a directory so
1966        // the atomic rewrite's open fails, exercising the cleanup path.
1967        let tmp = sibling(&path, &format!(".prune.{}.tmp", std::process::id()));
1968        std::fs::create_dir(&tmp).unwrap();
1969
1970        let result = prune(
1971            &path,
1972            &PruneOptions {
1973                older_than: None,
1974                max_size: Some(1),
1975                dry_run: false,
1976            },
1977        );
1978        assert!(result.is_err(), "a failing rewrite surfaces as an error");
1979        let _ = std::fs::remove_dir(&tmp);
1980    }
1981
1982    #[tokio::test]
1983    async fn scope_origin_id_overwrites_id_but_preserves_source() {
1984        // A daemon-side base context: source = Daemon (so `via_daemon` detection
1985        // keeps working) with the daemon's own invocation id.
1986        let base = RequestLogContext {
1987            invocation_id: "daemon-1".to_string(),
1988            source: Source::Daemon,
1989            mcp_tool: None,
1990        };
1991        CTX.scope(base, async {
1992            scope_origin_id("cli-42".to_string(), async {
1993                let ctx = current_context();
1994                // Correlation id now points at the originating CLI invocation…
1995                assert_eq!(ctx.invocation_id, "cli-42");
1996                // …while the source stays Daemon, so `via_daemon` is unaffected.
1997                assert_eq!(ctx.source, Source::Daemon);
1998            })
1999            .await;
2000            // The override is scoped: outside it, the base id is restored.
2001            assert_eq!(current_context().invocation_id, "daemon-1");
2002        })
2003        .await;
2004    }
2005}