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