1use 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
34const LOG_FILE_NAME: &str = "log.jsonl";
36
37#[cfg(unix)]
41const DEFAULT_KEEP_FILES: u32 = 3;
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
46#[serde(rename_all = "lowercase")]
47pub enum RecordKind {
48 #[default]
50 Invocation,
51 Http,
53 #[serde(other)]
55 Unknown,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
61#[serde(rename_all = "lowercase")]
62pub enum Source {
63 #[default]
65 Cli,
66 Mcp,
68 Daemon,
70 #[serde(other)]
72 Unknown,
73}
74
75#[derive(Debug, Clone, Default, Serialize, Deserialize)]
79pub struct LogRecord {
80 #[serde(default)]
83 pub id: String,
84 #[serde(default)]
86 pub invocation_id: String,
87 #[serde(default)]
89 pub kind: RecordKind,
90 #[serde(default)]
92 pub timestamp: String,
93 #[serde(default)]
95 pub hostname: String,
96 #[serde(default)]
98 pub pid: u32,
99 #[serde(default)]
101 pub omni_dev_version: String,
102 #[serde(default)]
104 pub cwd: String,
105 #[serde(default)]
107 pub system_user: String,
108
109 #[serde(default, skip_serializing_if = "Vec::is_empty")]
112 pub command: Vec<String>,
113 #[serde(default, skip_serializing_if = "Vec::is_empty")]
115 pub command_line: Vec<String>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub exit_code: Option<i32>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub duration_ms: Option<u64>,
122 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
124 pub env: BTreeMap<String, String>,
125 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub source: Option<Source>,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub mcp_tool: Option<String>,
131
132 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub service: Option<String>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub method: Option<String>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub url: Option<String>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub status_code: Option<u16>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub elapsed_ms: Option<u64>,
148 #[serde(default, skip_serializing_if = "is_false")]
150 pub via_daemon: bool,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub daemon_session_id: Option<String>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub auth_principal: Option<String>,
158 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
160 pub request_headers: BTreeMap<String, String>,
161 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
163 pub response_headers: BTreeMap<String, String>,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
166 pub request_body: Option<String>,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub response_body: Option<String>,
170 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
172 pub context: BTreeMap<String, String>,
173
174 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub error: Option<String>,
178}
179
180#[allow(clippy::trivially_copy_pass_by_ref)] fn is_false(b: &bool) -> bool {
183 !*b
184}
185
186impl LogRecord {
187 fn new(kind: RecordKind, invocation_id: String) -> Self {
189 Self {
190 id: new_id(),
191 invocation_id,
192 kind,
193 timestamp: now_rfc3339_millis(),
194 hostname: hostname(),
195 pid: std::process::id(),
196 omni_dev_version: crate::VERSION.to_string(),
197 cwd: cwd(),
198 system_user: system_user(),
199 ..Self::default()
200 }
201 }
202}
203
204#[derive(Debug, Clone)]
210pub struct RequestLogContext {
211 pub invocation_id: String,
213 pub source: Source,
215 pub mcp_tool: Option<String>,
217}
218
219impl Default for RequestLogContext {
220 fn default() -> Self {
221 Self {
222 invocation_id: new_id(),
223 source: Source::Cli,
224 mcp_tool: None,
225 }
226 }
227}
228
229impl RequestLogContext {
230 pub fn cli() -> Self {
232 Self {
233 invocation_id: new_id(),
234 source: Source::Cli,
235 mcp_tool: None,
236 }
237 }
238
239 pub fn mcp(tool: impl Into<String>) -> Self {
241 Self {
242 invocation_id: new_id(),
243 source: Source::Mcp,
244 mcp_tool: Some(tool.into()),
245 }
246 }
247}
248
249static GLOBAL: OnceLock<RequestLogContext> = OnceLock::new();
250
251tokio::task_local! {
252 pub static CTX: RequestLogContext;
254}
255
256pub fn set_global(ctx: RequestLogContext) {
259 let _ = GLOBAL.set(ctx);
260}
261
262pub fn current_context() -> RequestLogContext {
265 if let Ok(ctx) = CTX.try_with(RequestLogContext::clone) {
266 return ctx;
267 }
268 if let Some(ctx) = GLOBAL.get() {
269 return ctx.clone();
270 }
271 RequestLogContext::default()
272}
273
274pub async fn scope_origin_id<F, T>(origin_id: String, fut: F) -> T
284where
285 F: std::future::Future<Output = T>,
286{
287 let mut ctx = current_context();
288 ctx.invocation_id = origin_id;
289 CTX.scope(ctx, fut).await
290}
291
292pub fn disabled() -> bool {
294 env_flag("OMNI_DEV_LOG_DISABLE")
295}
296
297pub fn bodies_enabled() -> bool {
299 env_flag("OMNI_DEV_LOG_BODIES")
300}
301
302pub fn headers_enabled() -> bool {
304 env_flag("OMNI_DEV_LOG_HEADERS")
305}
306
307fn env_flag(name: &str) -> bool {
309 std::env::var(name).is_ok_and(|v| {
310 let v = v.trim().to_ascii_lowercase();
311 v == "1" || v == "true" || v == "yes"
312 })
313}
314
315pub fn log_file_path() -> Option<PathBuf> {
318 if let Ok(path) = std::env::var("OMNI_DEV_LOG_FILE") {
319 if !path.is_empty() {
320 return Some(PathBuf::from(path));
321 }
322 }
323 let base = dirs::state_dir().or_else(dirs::data_dir)?;
324 Some(base.join("omni-dev").join(LOG_FILE_NAME))
325}
326
327pub fn record(entry: &LogRecord) {
330 if disabled() {
331 return;
332 }
333 if let Err(e) = try_record(entry) {
334 tracing::debug!("request_log: failed to append record: {e}");
335 }
336}
337
338fn try_record(entry: &LogRecord) -> anyhow::Result<()> {
340 use anyhow::Context;
341
342 let path = log_file_path().context("could not resolve the log file path")?;
343 if let Some(parent) = path.parent() {
347 if !parent.as_os_str().is_empty() && !parent.exists() {
348 crate::daemon::paths::ensure_dir_0700(parent)?;
349 }
350 }
351 let mut line = serde_json::to_string(entry).context("failed to serialize record")?;
352 line.push('\n');
353 append_line(&path, &line)?;
354 Ok(())
355}
356
357#[cfg(unix)]
365fn append_line(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
366 use std::os::unix::fs::OpenOptionsExt;
367
368 if let Some(cfg) = rotation_config() {
371 return append_with_rotation(path, line, &cfg);
372 }
373
374 let file = std::fs::OpenOptions::new()
375 .append(true)
376 .create(true)
377 .mode(0o600)
378 .open(path)?;
379 crate::daemon::paths::ensure_handle_0600(&file)?;
380
381 if bodies_enabled() {
382 match nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusive) {
383 Ok(mut guard) => {
384 guard.write_all(line.as_bytes())?;
385 }
386 Err((mut file, _)) => {
387 file.write_all(line.as_bytes())?;
388 }
389 }
390 } else {
391 let mut file = file;
392 file.write_all(line.as_bytes())?;
393 }
394 Ok(())
395}
396
397#[cfg(not(unix))]
401fn append_line(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
402 let mut file = std::fs::OpenOptions::new()
403 .append(true)
404 .create(true)
405 .open(path)?;
406 file.write_all(line.as_bytes())?;
407 Ok(())
408}
409
410fn sibling(path: &Path, suffix: &str) -> PathBuf {
424 let mut name = path.as_os_str().to_owned();
425 name.push(suffix);
426 PathBuf::from(name)
427}
428
429pub(crate) fn parse_size(s: &str) -> anyhow::Result<u64> {
433 use anyhow::Context as _;
434
435 let lower = s.trim().to_ascii_lowercase();
436 if lower.is_empty() {
437 anyhow::bail!("empty size (expected e.g. 10mb, 512kb, 1048576)");
438 }
439 let split = lower
440 .find(|c: char| !c.is_ascii_digit() && c != '.')
441 .unwrap_or(lower.len());
442 let (num, unit) = lower.split_at(split);
443 let value: f64 = num
444 .parse()
445 .with_context(|| format!("invalid size number: {s}"))?;
446 if !value.is_finite() || value < 0.0 {
447 anyhow::bail!("invalid size: {s}");
448 }
449 let mult: u64 = match unit.trim() {
450 "" | "b" => 1,
451 "k" | "kb" | "kib" => 1024,
452 "m" | "mb" | "mib" => 1024 * 1024,
453 "g" | "gb" | "gib" => 1024 * 1024 * 1024,
454 other => anyhow::bail!("invalid size unit: {other} (use b, kb, mb, or gb)"),
455 };
456 Ok((value * mult as f64) as u64)
457}
458
459#[cfg(unix)]
463struct RotationConfig {
464 max_size: u64,
466 keep_files: u32,
468}
469
470#[cfg(unix)]
474fn rotation_config() -> Option<RotationConfig> {
475 let raw = std::env::var("OMNI_DEV_LOG_MAX_SIZE").ok()?;
476 if raw.trim().is_empty() {
477 return None;
478 }
479 let max_size = match parse_size(&raw) {
480 Ok(0) => return None,
481 Ok(n) => n,
482 Err(e) => {
483 tracing::debug!("request_log: ignoring invalid OMNI_DEV_LOG_MAX_SIZE: {e}");
484 return None;
485 }
486 };
487 let keep_files = std::env::var("OMNI_DEV_LOG_KEEP_FILES")
488 .ok()
489 .and_then(|v| v.trim().parse::<u32>().ok())
490 .unwrap_or(DEFAULT_KEEP_FILES);
491 Some(RotationConfig {
492 max_size,
493 keep_files,
494 })
495}
496
497#[cfg(unix)]
501fn rotate(path: &Path, keep_files: u32) -> anyhow::Result<()> {
502 if keep_files == 0 {
503 let _ = std::fs::remove_file(path);
506 return Ok(());
507 }
508 let _ = std::fs::remove_file(sibling(path, &format!(".{keep_files}")));
510 for i in (1..keep_files).rev() {
511 let from = sibling(path, &format!(".{i}"));
512 if from.exists() {
513 std::fs::rename(&from, sibling(path, &format!(".{}", i + 1)))?;
514 }
515 }
516 std::fs::rename(path, sibling(path, ".1"))?;
517 Ok(())
518}
519
520#[cfg(unix)]
526fn append_with_rotation(path: &Path, line: &str, cfg: &RotationConfig) -> anyhow::Result<()> {
527 use std::os::unix::fs::OpenOptionsExt;
528
529 let lock_path = sibling(path, ".lock");
530 let lock_file = std::fs::OpenOptions::new()
531 .create(true)
532 .write(true)
533 .truncate(false)
534 .mode(0o600)
535 .open(&lock_path)?;
536 crate::daemon::paths::ensure_handle_0600(&lock_file)?;
537 let _guard = nix::fcntl::Flock::lock(lock_file, nix::fcntl::FlockArg::LockExclusive).ok();
540
541 let current = std::fs::metadata(path).map_or(0, |m| m.len());
542 if current > 0 && current.saturating_add(line.len() as u64) > cfg.max_size {
543 if let Err(e) = rotate(path, cfg.keep_files) {
544 tracing::debug!("request_log: rotation failed, appending without rotating: {e}");
545 }
546 }
547
548 let mut file = std::fs::OpenOptions::new()
549 .append(true)
550 .create(true)
551 .mode(0o600)
552 .open(path)?;
553 crate::daemon::paths::ensure_handle_0600(&file)?;
554 file.write_all(line.as_bytes())?;
555 Ok(())
556}
557
558pub struct PruneOptions {
560 pub older_than: Option<DateTime<Utc>>,
564 pub max_size: Option<u64>,
567 pub dry_run: bool,
569}
570
571pub struct PruneOutcome {
573 pub removed: usize,
575 pub kept: usize,
577 pub bytes_before: u64,
579 pub bytes_after: u64,
581}
582
583pub fn prune(path: &Path, opts: &PruneOptions) -> anyhow::Result<PruneOutcome> {
592 use anyhow::Context as _;
593
594 let data = match std::fs::read(path) {
595 Ok(data) => data,
596 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
597 return Ok(PruneOutcome {
598 removed: 0,
599 kept: 0,
600 bytes_before: 0,
601 bytes_after: 0,
602 });
603 }
604 Err(e) => return Err(e).context("failed to read the log file"),
605 };
606 let bytes_before = data.len() as u64;
607 let text = String::from_utf8_lossy(&data);
608
609 let all: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
612 let aged: Vec<&str> = all
613 .iter()
614 .copied()
615 .filter(|line| keep_by_age(line, opts.older_than))
616 .collect();
617
618 let kept: &[&str] = match opts.max_size {
619 None => &aged,
620 Some(max) => keep_by_size(&aged, max),
621 };
622
623 let bytes_after: u64 = kept.iter().map(|l| l.len() as u64 + 1).sum();
624 let outcome = PruneOutcome {
625 removed: all.len() - kept.len(),
626 kept: kept.len(),
627 bytes_before,
628 bytes_after,
629 };
630
631 if !opts.dry_run && outcome.removed > 0 {
632 rewrite_atomically(path, kept)?;
633 }
634 Ok(outcome)
635}
636
637fn keep_by_age(line: &str, older_than: Option<DateTime<Utc>>) -> bool {
640 let Some(cutoff) = older_than else {
641 return true;
642 };
643 match serde_json::from_str::<LogRecord>(line) {
644 Ok(rec) => match DateTime::parse_from_rfc3339(&rec.timestamp) {
645 Ok(ts) => ts.with_timezone(&Utc) >= cutoff,
646 Err(_) => true,
647 },
648 Err(_) => true,
649 }
650}
651
652fn keep_by_size<'a>(lines: &'a [&'a str], max: u64) -> &'a [&'a str] {
655 let mut acc = 0u64;
656 let mut start = lines.len();
657 for (i, line) in lines.iter().enumerate().rev() {
658 acc += line.len() as u64 + 1;
659 if acc > max {
660 break;
661 }
662 start = i;
663 }
664 if start == lines.len() && !lines.is_empty() {
665 start = lines.len() - 1; }
667 &lines[start..]
668}
669
670fn rewrite_atomically(path: &Path, lines: &[&str]) -> anyhow::Result<()> {
673 let tmp = sibling(path, &format!(".prune.{}.tmp", std::process::id()));
674 let result = (|| -> anyhow::Result<()> {
675 let mut options = std::fs::OpenOptions::new();
676 options.create(true).write(true).truncate(true);
677 #[cfg(unix)]
678 {
679 use std::os::unix::fs::OpenOptionsExt;
680 options.mode(0o600);
681 }
682 let mut file = options.open(&tmp)?;
683 #[cfg(unix)]
684 crate::daemon::paths::ensure_handle_0600(&file)?;
685 for line in lines {
686 file.write_all(line.as_bytes())?;
687 file.write_all(b"\n")?;
688 }
689 file.flush()?;
690 std::fs::rename(&tmp, path)?;
691 Ok(())
692 })();
693 if result.is_err() {
694 let _ = std::fs::remove_file(&tmp);
695 }
696 result
697}
698
699#[derive(Debug, Clone)]
701pub struct InvocationOutcome {
702 pub command: Vec<String>,
704 pub command_line: Vec<String>,
706 pub exit_code: i32,
708 pub error: Option<String>,
710 pub duration: Duration,
712}
713
714pub fn record_invocation(outcome: InvocationOutcome) {
716 let ctx = current_context();
717 let mut rec = LogRecord::new(RecordKind::Invocation, ctx.invocation_id);
718 rec.source = Some(ctx.source);
719 rec.mcp_tool = ctx.mcp_tool;
720 rec.command = outcome.command;
721 rec.command_line = scrub_argv(&outcome.command_line);
722 rec.exit_code = Some(outcome.exit_code);
723 rec.error = outcome.error;
724 rec.duration_ms = Some(outcome.duration.as_millis() as u64);
725 rec.env = whitelisted_env();
726 record(&rec);
727}
728
729#[derive(Debug, Clone, Default)]
732pub struct HttpExtra {
733 pub via_daemon: bool,
735 pub daemon_session_id: Option<String>,
737 pub auth_principal: Option<String>,
739 pub request_headers: BTreeMap<String, String>,
741 pub response_headers: BTreeMap<String, String>,
743 pub request_body: Option<String>,
745 pub response_body: Option<String>,
747 pub context: BTreeMap<String, String>,
749}
750
751pub fn record_http(
753 service: &str,
754 method: &str,
755 url: &str,
756 started: Instant,
757 status: Option<u16>,
758 error: Option<&str>,
759) {
760 record_http_with(
761 service,
762 method,
763 url,
764 started,
765 status,
766 error,
767 HttpExtra::default(),
768 );
769}
770
771pub fn record_http_result(
777 service: &str,
778 method: &str,
779 url: &str,
780 started: Instant,
781 result: &reqwest::Result<reqwest::Response>,
782) {
783 match result {
784 Ok(response) => {
785 record_http(
786 service,
787 method,
788 url,
789 started,
790 Some(response.status().as_u16()),
791 None,
792 );
793 }
794 Err(error) => {
795 record_http(
796 service,
797 method,
798 url,
799 started,
800 None,
801 Some(&error.to_string()),
802 );
803 }
804 }
805}
806
807#[allow(clippy::too_many_arguments)]
814pub fn record_http_with(
815 service: &str,
816 method: &str,
817 url: &str,
818 started: Instant,
819 status: Option<u16>,
820 error: Option<&str>,
821 extra: HttpExtra,
822) {
823 if disabled() {
824 return;
825 }
826 let ctx = current_context();
827 let mut rec = LogRecord::new(RecordKind::Http, ctx.invocation_id);
828 rec.source = Some(ctx.source);
829 rec.mcp_tool = ctx.mcp_tool;
830 rec.service = Some(service.to_string());
831 rec.method = Some(method.to_string());
832 rec.url = Some(redact_url(url));
833 rec.status_code = status;
834 rec.elapsed_ms = Some(started.elapsed().as_millis() as u64);
835 rec.error = error.map(str::to_string);
836 rec.via_daemon = extra.via_daemon;
837 rec.daemon_session_id = extra.daemon_session_id;
838 rec.auth_principal = extra.auth_principal;
839 rec.context = extra.context;
840 if headers_enabled() {
841 rec.request_headers = redact_headers(&extra.request_headers);
842 rec.response_headers = redact_headers(&extra.response_headers);
843 }
844 if bodies_enabled() {
845 rec.request_body = extra.request_body;
846 rec.response_body = extra.response_body;
847 }
848 record(&rec);
849}
850
851const SENSITIVE_HEADERS: &[&str] = &[
853 "authorization",
854 "proxy-authorization",
855 "cookie",
856 "set-cookie",
857 "x-api-key",
858 "api-key",
859 "dd-api-key",
860 "dd-application-key",
861 "x-datadog-api-key",
862 "x-datadog-application-key",
863 "x-omni-bridge",
864 "x-omni-bridge-target",
865];
866
867const SENSITIVE_HEADER_MARKERS: &[&str] = &[
871 "auth",
872 "token",
873 "secret",
874 "key",
875 "cookie",
876 "password",
877 "session",
878 "signature",
879 "credential",
880];
881
882pub fn redact_headers(headers: &BTreeMap<String, String>) -> BTreeMap<String, String> {
887 headers
888 .iter()
889 .map(|(name, value)| {
890 let lower = name.to_ascii_lowercase();
891 let redacted = SENSITIVE_HEADERS.contains(&lower.as_str())
892 || SENSITIVE_HEADER_MARKERS
893 .iter()
894 .any(|marker| lower.contains(marker));
895 (
896 name.clone(),
897 if redacted {
898 "REDACTED".to_string()
899 } else {
900 value.clone()
901 },
902 )
903 })
904 .collect()
905}
906
907const SECRETISH_FLAG_WORDS: &[&str] = &["token", "secret", "password", "passwd", "key"];
911
912fn is_secretish_flag(name: &str) -> bool {
916 let segments: Vec<String> = name
917 .split(['-', '_'])
918 .map(str::to_ascii_lowercase)
919 .collect();
920 let takes_path = matches!(segments.last().map(String::as_str), Some("file" | "path"));
921 !takes_path
922 && segments
923 .iter()
924 .any(|segment| SECRETISH_FLAG_WORDS.contains(&segment.as_str()))
925}
926
927fn scrub_header_arg(value: &str) -> Option<String> {
931 let Some((name, _)) = value.split_once(':') else {
932 return Some("REDACTED".to_string());
933 };
934 SENSITIVE_HEADERS
935 .contains(&name.trim().to_ascii_lowercase().as_str())
936 .then(|| format!("{}: REDACTED", name.trim()))
937}
938
939fn scrub_flag_value(name: &str, value: &str) -> Option<String> {
943 match name {
944 "header" => scrub_header_arg(value),
945 "body" => (!value.starts_with('@')).then(|| "REDACTED".to_string()),
946 _ if is_secretish_flag(name) => Some("REDACTED".to_string()),
947 _ => None,
948 }
949}
950
951fn scrub_argv(argv: &[String]) -> Vec<String> {
963 scrub_flag_secrets(argv)
964 .iter()
965 .map(|arg| redact_url(arg))
966 .collect()
967}
968
969fn scrub_flag_secrets(argv: &[String]) -> Vec<String> {
974 let mut out = Vec::with_capacity(argv.len());
975 let mut i = 0;
976 while i < argv.len() {
977 let arg = &argv[i];
978 i += 1;
979 let Some(flag_body) = arg.strip_prefix("--") else {
980 out.push(arg.clone());
981 continue;
982 };
983 if let Some((name, value)) = flag_body.split_once('=') {
984 match scrub_flag_value(name, value) {
985 Some(scrubbed) => out.push(format!("--{name}={scrubbed}")),
986 None => out.push(arg.clone()),
987 }
988 } else {
989 out.push(arg.clone());
990 let takes_secret_value =
991 matches!(flag_body, "header" | "body") || is_secretish_flag(flag_body);
992 if takes_secret_value {
993 if let Some(value) = argv.get(i) {
994 i += 1;
995 out.push(scrub_flag_value(flag_body, value).unwrap_or_else(|| value.clone()));
996 }
997 }
998 }
999 }
1000 out
1001}
1002
1003const SENSITIVE_QUERY_KEYS: &[&str] = &["sig", "sas", "jwt", "auth"];
1005
1006const SENSITIVE_QUERY_KEY_SUFFIXES: &[&str] = &[
1009 "token",
1010 "secret",
1011 "password",
1012 "passwd",
1013 "signature",
1014 "apikey",
1015 "api_key",
1016 "api-key",
1017];
1018
1019const SENSITIVE_QUERY_KEY_PREFIXES: &[&str] = &["x-amz-", "x-goog-"];
1021
1022fn sensitive_query_key(key: &str) -> bool {
1024 let key = key.to_ascii_lowercase();
1025 SENSITIVE_QUERY_KEYS.contains(&key.as_str())
1026 || SENSITIVE_QUERY_KEY_SUFFIXES
1027 .iter()
1028 .any(|suffix| key.ends_with(suffix))
1029 || SENSITIVE_QUERY_KEY_PREFIXES
1030 .iter()
1031 .any(|prefix| key.starts_with(prefix))
1032}
1033
1034fn redact_pairs(pairs: &str) -> String {
1038 pairs
1039 .split('&')
1040 .map(|segment| match segment.split_once('=') {
1041 Some((raw_key, _)) => {
1042 let sensitive = url::form_urlencoded::parse(raw_key.as_bytes())
1045 .next()
1046 .is_some_and(|(key, _)| sensitive_query_key(&key));
1047 if sensitive {
1048 format!("{raw_key}=REDACTED")
1049 } else {
1050 segment.to_string()
1051 }
1052 }
1053 None => segment.to_string(),
1055 })
1056 .collect::<Vec<_>>()
1057 .join("&")
1058}
1059
1060fn redact_url(url: &str) -> String {
1066 let (rest, fragment) = url
1067 .split_once('#')
1068 .map_or((url, None), |(rest, fragment)| (rest, Some(fragment)));
1069 let (prefix, query) = rest
1070 .split_once('?')
1071 .map_or((rest, None), |(prefix, query)| (prefix, Some(query)));
1072 let mut out = prefix.to_string();
1073 if let Some(query) = query {
1074 out.push('?');
1075 out.push_str(&redact_pairs(query));
1076 }
1077 if let Some(fragment) = fragment {
1078 out.push('#');
1079 out.push_str(&redact_pairs(fragment));
1080 }
1081 out
1082}
1083
1084pub fn new_id() -> String {
1090 let millis = chrono::Utc::now().timestamp_millis().max(0);
1091 let suffix = rand::random::<u64>();
1092 format!("{millis:013}-{suffix:016x}")
1093}
1094
1095fn now_rfc3339_millis() -> String {
1097 chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
1098}
1099
1100fn cwd() -> String {
1102 std::env::current_dir()
1103 .map(|p| p.display().to_string())
1104 .unwrap_or_default()
1105}
1106
1107fn system_user() -> String {
1109 if let Ok(user) = std::env::var("USER") {
1110 if !user.is_empty() {
1111 return user;
1112 }
1113 }
1114 #[cfg(unix)]
1115 {
1116 if let Ok(Some(user)) = nix::unistd::User::from_uid(nix::unistd::geteuid()) {
1117 return user.name;
1118 }
1119 }
1120 String::new()
1121}
1122
1123fn hostname() -> String {
1125 #[cfg(unix)]
1126 {
1127 if let Ok(name) = nix::unistd::gethostname() {
1128 if let Some(name) = name.to_str() {
1129 if !name.is_empty() {
1130 return name.to_string();
1131 }
1132 }
1133 }
1134 }
1135 std::env::var("HOSTNAME").unwrap_or_default()
1136}
1137
1138const SECRETISH: &[&str] = &["TOKEN", "SECRET", "KEY", "PASSWORD", "PASSWD"];
1141
1142fn whitelisted_env() -> BTreeMap<String, String> {
1144 std::env::vars()
1145 .filter(|(k, _)| k.starts_with("OMNI_DEV_"))
1146 .map(|(k, v)| {
1147 let secretish = SECRETISH.iter().any(|needle| k.contains(needle));
1148 let value = if secretish { "REDACTED".to_string() } else { v };
1149 (k, value)
1150 })
1151 .collect()
1152}
1153
1154#[cfg(test)]
1155#[allow(clippy::unwrap_used, clippy::expect_used)]
1156mod tests {
1157 use super::*;
1158
1159 #[test]
1160 fn record_round_trips_through_json() {
1161 let mut rec = LogRecord::new(RecordKind::Http, "inv-1".to_string());
1162 rec.service = Some("jira".to_string());
1163 rec.method = Some("GET".to_string());
1164 rec.url = Some("https://example.atlassian.net/rest/api/3/issue/X-1".to_string());
1165 rec.status_code = Some(200);
1166 rec.elapsed_ms = Some(42);
1167
1168 let line = serde_json::to_string(&rec).unwrap();
1169 let back: LogRecord = serde_json::from_str(&line).unwrap();
1170 assert_eq!(back.invocation_id, "inv-1");
1171 assert_eq!(back.kind, RecordKind::Http);
1172 assert_eq!(back.service.as_deref(), Some("jira"));
1173 assert_eq!(back.status_code, Some(200));
1174 }
1175
1176 #[test]
1177 fn reader_tolerates_unknown_fields() {
1178 let line = r#"{"id":"x","invocation_id":"i","kind":"http","method":"GET",
1179 "future_field":{"nested":true},"another":42}"#;
1180 let rec: LogRecord = serde_json::from_str(line).unwrap();
1181 assert_eq!(rec.kind, RecordKind::Http);
1182 assert_eq!(rec.method.as_deref(), Some("GET"));
1183 }
1184
1185 #[test]
1186 fn reader_tolerates_missing_newer_fields() {
1187 let line = r#"{"kind":"invocation","command":["git","view"]}"#;
1189 let rec: LogRecord = serde_json::from_str(line).unwrap();
1190 assert_eq!(rec.kind, RecordKind::Invocation);
1191 assert_eq!(rec.command, vec!["git", "view"]);
1192 assert!(rec.status_code.is_none());
1193 assert!(rec.id.is_empty());
1194 }
1195
1196 #[test]
1197 fn unknown_kind_and_source_do_not_fail() {
1198 let line = r#"{"kind":"telemetry","source":"webhook"}"#;
1199 let rec: LogRecord = serde_json::from_str(line).unwrap();
1200 assert_eq!(rec.kind, RecordKind::Unknown);
1201 assert_eq!(rec.source, Some(Source::Unknown));
1202 }
1203
1204 #[test]
1205 fn optional_fields_are_skipped_when_empty() {
1206 let rec = LogRecord::new(RecordKind::Invocation, "i".to_string());
1207 let line = serde_json::to_string(&rec).unwrap();
1208 assert!(!line.contains("status_code"));
1210 assert!(!line.contains("request_headers"));
1211 assert!(!line.contains("via_daemon"));
1212 assert!(!line.contains("\"env\""));
1213 }
1214
1215 #[test]
1216 fn ids_are_time_sortable() {
1217 let a = new_id();
1218 std::thread::sleep(std::time::Duration::from_millis(2));
1219 let b = new_id();
1220 assert!(a < b, "{a} should sort before {b}");
1221 }
1222
1223 #[test]
1224 fn sensitive_headers_are_redacted() {
1225 let mut headers = BTreeMap::new();
1226 headers.insert("Authorization".to_string(), "Bearer secret".to_string());
1227 headers.insert("X-Api-Key".to_string(), "abc123".to_string());
1228 headers.insert("Content-Type".to_string(), "application/json".to_string());
1229 let out = redact_headers(&headers);
1230 assert_eq!(out["Authorization"], "REDACTED");
1231 assert_eq!(out["X-Api-Key"], "REDACTED");
1232 assert_eq!(out["Content-Type"], "application/json");
1233 }
1234
1235 fn argv(args: &[&str]) -> Vec<String> {
1236 args.iter().copied().map(String::from).collect()
1237 }
1238
1239 #[test]
1240 fn scrub_argv_redacts_sensitive_header_in_both_forms() {
1241 let out = scrub_argv(&argv(&[
1242 "omni-dev",
1243 "--header",
1244 "Authorization: Bearer sekret",
1245 "--header=Cookie: session=abc",
1246 ]));
1247 assert_eq!(
1248 out,
1249 argv(&[
1250 "omni-dev",
1251 "--header",
1252 "Authorization: REDACTED",
1253 "--header=Cookie: REDACTED",
1254 ])
1255 );
1256 }
1257
1258 #[test]
1259 fn scrub_argv_keeps_non_sensitive_headers() {
1260 let input = argv(&["omni-dev", "--header", "Content-Type: application/json"]);
1261 assert_eq!(scrub_argv(&input), input);
1262 }
1263
1264 #[test]
1265 fn scrub_argv_redacts_colonless_header_wholesale() {
1266 let out = scrub_argv(&argv(&["omni-dev", "--header", "sekret"]));
1267 assert_eq!(out, argv(&["omni-dev", "--header", "REDACTED"]));
1268 }
1269
1270 #[test]
1271 fn scrub_argv_redacts_inline_body_but_keeps_at_file() {
1272 let out = scrub_argv(&argv(&["omni-dev", "--body", r#"{"secret":1}"#]));
1273 assert_eq!(out, argv(&["omni-dev", "--body", "REDACTED"]));
1274
1275 let file_form = argv(&["omni-dev", "--body", "@payload.json"]);
1276 assert_eq!(scrub_argv(&file_form), file_form);
1277
1278 let out = scrub_argv(&argv(&["omni-dev", "--body=sekret"]));
1279 assert_eq!(out, argv(&["omni-dev", "--body=REDACTED"]));
1280 }
1281
1282 #[test]
1283 fn scrub_argv_redacts_secretish_flag_values() {
1284 let out = scrub_argv(&argv(&["omni-dev", "--api-key", "abc", "--auth-token=xyz"]));
1285 assert_eq!(
1286 out,
1287 argv(&["omni-dev", "--api-key", "REDACTED", "--auth-token=REDACTED"])
1288 );
1289 }
1290
1291 #[test]
1292 fn scrub_argv_exempts_path_flags_and_positionals() {
1293 let input = argv(&["omni-dev", "--token-file", "/tmp/t", "PROJ-123"]);
1294 assert_eq!(scrub_argv(&input), input);
1295 }
1296
1297 #[test]
1298 fn scrub_argv_redacts_secret_bearing_url_query_in_both_forms() {
1299 let space = scrub_argv(&argv(&[
1303 "omni-dev",
1304 "browser",
1305 "bridge",
1306 "request",
1307 "--url",
1308 "/api/export?access_token=hunter2&sig=deadbeef&page=3",
1309 ]));
1310 assert_eq!(
1311 *space.last().unwrap(),
1312 "/api/export?access_token=REDACTED&sig=REDACTED&page=3"
1313 );
1314
1315 let eq_form = scrub_argv(&argv(&[
1316 "omni-dev",
1317 "--url=/api/export?access_token=hunter2&page=3",
1318 ]));
1319 assert_eq!(
1320 *eq_form.last().unwrap(),
1321 "--url=/api/export?access_token=REDACTED&page=3"
1322 );
1323
1324 let positional = scrub_argv(&argv(&["omni-dev", "https://h/cb#id_token=xyz"]));
1325 assert_eq!(
1326 *positional.last().unwrap(),
1327 "https://h/cb#id_token=REDACTED"
1328 );
1329 }
1330
1331 #[test]
1332 fn scrub_argv_leaves_benign_argv_byte_identical() {
1333 let input = argv(&[
1334 "omni-dev",
1335 "browser",
1336 "bridge",
1337 "request",
1338 "--control-port",
1339 "19998",
1340 "--url",
1341 "/api/export?page=3&sort=asc",
1342 ]);
1343 assert_eq!(scrub_argv(&input), input);
1344 }
1345
1346 #[test]
1347 fn scrub_argv_handles_trailing_flag_without_value() {
1348 let input = argv(&["omni-dev", "--body"]);
1349 assert_eq!(scrub_argv(&input), input);
1350 }
1351
1352 #[cfg(unix)]
1353 #[test]
1354 fn append_line_creates_file_owner_only() {
1355 use std::os::unix::fs::PermissionsExt;
1356 let dir = tempfile::tempdir().unwrap();
1357 let path = dir.path().join("log.jsonl");
1358 append_line(&path, "{\"kind\":\"http\"}\n").unwrap();
1359 assert_eq!(
1360 std::fs::read_to_string(&path).unwrap(),
1361 "{\"kind\":\"http\"}\n"
1362 );
1363 assert_eq!(
1364 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1365 0o600
1366 );
1367 }
1368
1369 #[cfg(unix)]
1370 #[test]
1371 fn append_line_retightens_preexisting_loose_file() {
1372 use std::os::unix::fs::PermissionsExt;
1373 let dir = tempfile::tempdir().unwrap();
1374 let path = dir.path().join("log.jsonl");
1375 std::fs::write(&path, "old\n").unwrap();
1376 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1377 append_line(&path, "new\n").unwrap();
1378 assert_eq!(std::fs::read_to_string(&path).unwrap(), "old\nnew\n");
1379 assert_eq!(
1380 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1381 0o600
1382 );
1383 }
1384
1385 #[test]
1386 fn off_list_secretish_headers_are_redacted() {
1387 let mut headers = BTreeMap::new();
1388 for name in [
1389 "X-Auth-Token",
1390 "x-amz-security-token",
1391 "X-Goog-Api-Key",
1392 "x-csrf-token",
1393 "X-Vendor-Token",
1394 "X-Omni-Bridge",
1395 ] {
1396 headers.insert(name.to_string(), "secret-value".to_string());
1397 }
1398 for name in [
1399 "Content-Type",
1400 "Accept",
1401 "User-Agent",
1402 "x-request-id",
1403 "traceparent",
1404 ] {
1405 headers.insert(name.to_string(), "plain-value".to_string());
1406 }
1407 let out = redact_headers(&headers);
1408 assert_eq!(out["X-Auth-Token"], "REDACTED");
1409 assert_eq!(out["x-amz-security-token"], "REDACTED");
1410 assert_eq!(out["X-Goog-Api-Key"], "REDACTED");
1411 assert_eq!(out["x-csrf-token"], "REDACTED");
1412 assert_eq!(out["X-Vendor-Token"], "REDACTED");
1413 assert_eq!(out["X-Omni-Bridge"], "REDACTED");
1414 assert_eq!(out["Content-Type"], "plain-value");
1415 assert_eq!(out["Accept"], "plain-value");
1416 assert_eq!(out["User-Agent"], "plain-value");
1417 assert_eq!(out["x-request-id"], "plain-value");
1418 assert_eq!(out["traceparent"], "plain-value");
1419 }
1420
1421 #[test]
1422 fn url_without_query_is_unchanged() {
1423 assert_eq!(redact_url("https://h/p"), "https://h/p");
1424 assert_eq!(redact_url("/relative/p"), "/relative/p");
1425 }
1426
1427 #[test]
1428 fn benign_query_is_byte_identical() {
1429 let url = "https://h/p?q=a%20b&page=2&&x=y+z&keyword=k&sort_key=s&token_type=bearer";
1430 assert_eq!(redact_url(url), url);
1431 }
1432
1433 #[test]
1434 fn sensitive_query_values_are_redacted() {
1435 let url = "https://h/p?token=a&access_token=b&client_secret=c&api_key=d&x=1";
1436 assert_eq!(
1437 redact_url(url),
1438 "https://h/p?token=REDACTED&access_token=REDACTED&client_secret=REDACTED\
1439 &api_key=REDACTED&x=1"
1440 );
1441 }
1442
1443 #[test]
1444 fn presigned_s3_query_is_redacted() {
1445 let url = "https://bucket.s3.amazonaws.com/key?X-Amz-Algorithm=AWS4-HMAC-SHA256\
1446 &X-Amz-Credential=AKIA%2F20260703%2Fus-east-1%2Fs3%2Faws4_request\
1447 &X-Amz-Date=20260703T000000Z&X-Amz-Expires=3600\
1448 &X-Amz-SignedHeaders=host&X-Amz-Signature=deadbeef";
1449 assert_eq!(
1450 redact_url(url),
1451 "https://bucket.s3.amazonaws.com/key?X-Amz-Algorithm=REDACTED\
1452 &X-Amz-Credential=REDACTED&X-Amz-Date=REDACTED&X-Amz-Expires=REDACTED\
1453 &X-Amz-SignedHeaders=REDACTED&X-Amz-Signature=REDACTED"
1454 );
1455 }
1456
1457 #[test]
1458 fn key_matching_is_case_insensitive() {
1459 assert_eq!(
1460 redact_url("/p?TOKEN=x&Api_Key=y&X-Amz-Signature=z"),
1461 "/p?TOKEN=REDACTED&Api_Key=REDACTED&X-Amz-Signature=REDACTED"
1462 );
1463 }
1464
1465 #[test]
1466 fn repeated_sensitive_keys_are_each_redacted() {
1467 assert_eq!(redact_url("/p?sig=a&sig=b"), "/p?sig=REDACTED&sig=REDACTED");
1468 }
1469
1470 #[test]
1471 fn valueless_key_is_left_alone() {
1472 assert_eq!(redact_url("/p?token"), "/p?token");
1473 assert_eq!(redact_url("/p?token="), "/p?token=REDACTED");
1474 }
1475
1476 #[test]
1477 fn relative_url_query_is_redacted() {
1478 assert_eq!(
1479 redact_url("/api/foo?sig=abc&x=y"),
1480 "/api/foo?sig=REDACTED&x=y"
1481 );
1482 }
1483
1484 #[test]
1485 fn fragment_credentials_are_redacted() {
1486 assert_eq!(
1487 redact_url("https://h/cb#access_token=xyz&token_type=bearer"),
1488 "https://h/cb#access_token=REDACTED&token_type=bearer"
1489 );
1490 }
1491
1492 #[test]
1493 fn query_and_fragment_are_scrubbed_independently() {
1494 assert_eq!(
1495 redact_url("/p?sig=a#id_token=b"),
1496 "/p?sig=REDACTED#id_token=REDACTED"
1497 );
1498 }
1499
1500 #[test]
1501 fn question_mark_in_fragment_is_not_parsed_as_query() {
1502 assert_eq!(
1506 redact_url("https://h/p#frag?token=x"),
1507 "https://h/p#frag?token=REDACTED"
1508 );
1509 }
1510
1511 #[test]
1512 fn encoded_sensitive_key_is_decoded_before_matching() {
1513 assert_eq!(
1514 redact_url("/p?access%5Ftoken=v"),
1515 "/p?access%5Ftoken=REDACTED"
1516 );
1517 }
1518
1519 #[test]
1520 fn empty_query_is_unchanged() {
1521 assert_eq!(redact_url("https://h/p?"), "https://h/p?");
1522 assert_eq!(redact_url("https://h/p?#f"), "https://h/p?#f");
1523 }
1524
1525 #[test]
1526 fn env_flag_parses_truthy_values() {
1527 std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "1");
1528 assert!(env_flag("OMNI_DEV_TEST_FLAG_ABC"));
1529 std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "TRUE");
1530 assert!(env_flag("OMNI_DEV_TEST_FLAG_ABC"));
1531 std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "0");
1532 assert!(!env_flag("OMNI_DEV_TEST_FLAG_ABC"));
1533 std::env::remove_var("OMNI_DEV_TEST_FLAG_ABC");
1534 assert!(!env_flag("OMNI_DEV_TEST_FLAG_ABC"));
1535 }
1536
1537 #[test]
1538 fn parse_size_handles_units_and_bare_bytes() {
1539 assert_eq!(parse_size("1048576").unwrap(), 1024 * 1024);
1540 assert_eq!(parse_size("512b").unwrap(), 512);
1541 assert_eq!(parse_size("10kb").unwrap(), 10 * 1024);
1542 assert_eq!(parse_size("2K").unwrap(), 2 * 1024);
1543 assert_eq!(parse_size("3mb").unwrap(), 3 * 1024 * 1024);
1544 assert_eq!(parse_size("1gb").unwrap(), 1024 * 1024 * 1024);
1545 assert_eq!(parse_size("1.5mb").unwrap(), (1.5 * 1024.0 * 1024.0) as u64);
1546 assert_eq!(parse_size(" 4mib ").unwrap(), 4 * 1024 * 1024);
1547 }
1548
1549 #[test]
1550 fn parse_size_rejects_garbage() {
1551 assert!(parse_size("").is_err());
1552 assert!(parse_size("mb").is_err());
1553 assert!(parse_size("10tb").is_err());
1554 assert!(parse_size("-5mb").is_err());
1555 }
1556
1557 #[test]
1558 fn sibling_appends_to_final_component() {
1559 let base = Path::new("/tmp/omni/log.jsonl");
1560 assert_eq!(sibling(base, ".1"), Path::new("/tmp/omni/log.jsonl.1"));
1561 assert_eq!(
1562 sibling(base, ".lock"),
1563 Path::new("/tmp/omni/log.jsonl.lock")
1564 );
1565 }
1566
1567 #[test]
1568 fn keep_by_size_keeps_most_recent_that_fit() {
1569 let lines = ["aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc", "dddddddddd"];
1571 let refs: Vec<&str> = lines.to_vec();
1572
1573 assert_eq!(keep_by_size(&refs, 22), &["cccccccccc", "dddddddddd"]);
1575 assert_eq!(keep_by_size(&refs, 1), &["dddddddddd"]);
1577 assert_eq!(keep_by_size(&refs, 10_000), &refs[..]);
1579 assert!(keep_by_size(&[], 100).is_empty());
1581 }
1582
1583 #[test]
1584 fn keep_by_age_is_conservative_on_undateable_lines() {
1585 let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
1586 .unwrap()
1587 .with_timezone(&Utc);
1588 let old = r#"{"kind":"http","timestamp":"2026-01-01T00:00:00.000Z"}"#;
1589 let new = r#"{"kind":"http","timestamp":"2026-12-01T00:00:00.000Z"}"#;
1590 let undated = r#"{"kind":"http"}"#;
1591 let malformed = "not json at all";
1592
1593 assert!(!keep_by_age(old, Some(cutoff)));
1594 assert!(keep_by_age(new, Some(cutoff)));
1595 assert!(keep_by_age(undated, Some(cutoff)), "undated is kept");
1596 assert!(keep_by_age(malformed, Some(cutoff)), "malformed is kept");
1597 assert!(keep_by_age(old, None), "no filter keeps everything");
1598 }
1599
1600 fn http_line(id: &str, ts: &str) -> String {
1601 format!(r#"{{"id":"{id}","kind":"http","timestamp":"{ts}"}}"#)
1602 }
1603
1604 #[test]
1605 fn prune_by_age_drops_old_records_and_rewrites_atomically() {
1606 use std::os::unix::fs::PermissionsExt;
1607
1608 let dir = tempfile::tempdir().unwrap();
1609 let path = dir.path().join("log.jsonl");
1610 let body = format!(
1611 "{}\n{}\n{}\n",
1612 http_line("1", "2026-01-01T00:00:00.000Z"),
1613 http_line("2", "2026-06-15T00:00:00.000Z"),
1614 http_line("3", "2026-12-31T00:00:00.000Z"),
1615 );
1616 std::fs::write(&path, &body).unwrap();
1617 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1618
1619 let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
1620 .unwrap()
1621 .with_timezone(&Utc);
1622 let outcome = prune(
1623 &path,
1624 &PruneOptions {
1625 older_than: Some(cutoff),
1626 max_size: None,
1627 dry_run: false,
1628 },
1629 )
1630 .unwrap();
1631
1632 assert_eq!(outcome.removed, 1);
1633 assert_eq!(outcome.kept, 2);
1634 let contents = std::fs::read_to_string(&path).unwrap();
1635 assert!(!contents.contains(r#""id":"1""#));
1636 assert!(contents.contains(r#""id":"2""#));
1637 assert!(contents.contains(r#""id":"3""#));
1638 assert_eq!(
1640 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1641 0o600
1642 );
1643 }
1644
1645 #[test]
1646 fn prune_dry_run_reports_without_modifying() {
1647 let dir = tempfile::tempdir().unwrap();
1648 let path = dir.path().join("log.jsonl");
1649 let body = format!(
1650 "{}\n{}\n",
1651 http_line("1", "2026-01-01T00:00:00.000Z"),
1652 http_line("2", "2026-12-31T00:00:00.000Z"),
1653 );
1654 std::fs::write(&path, &body).unwrap();
1655
1656 let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
1657 .unwrap()
1658 .with_timezone(&Utc);
1659 let outcome = prune(
1660 &path,
1661 &PruneOptions {
1662 older_than: Some(cutoff),
1663 max_size: None,
1664 dry_run: true,
1665 },
1666 )
1667 .unwrap();
1668
1669 assert_eq!(outcome.removed, 1);
1670 assert_eq!(std::fs::read_to_string(&path).unwrap(), body);
1672 }
1673
1674 #[test]
1675 fn prune_by_size_keeps_the_newest_that_fit() {
1676 let dir = tempfile::tempdir().unwrap();
1677 let path = dir.path().join("log.jsonl");
1678 let l1 = http_line("1", "2026-01-01T00:00:00.000Z");
1679 let l2 = http_line("2", "2026-06-15T00:00:00.000Z");
1680 let l3 = http_line("3", "2026-12-31T00:00:00.000Z");
1681 std::fs::write(&path, format!("{l1}\n{l2}\n{l3}\n")).unwrap();
1682
1683 let budget = (l2.len() + 1 + l3.len() + 1) as u64;
1685 let outcome = prune(
1686 &path,
1687 &PruneOptions {
1688 older_than: None,
1689 max_size: Some(budget),
1690 dry_run: false,
1691 },
1692 )
1693 .unwrap();
1694
1695 assert_eq!(outcome.removed, 1);
1696 assert_eq!(outcome.kept, 2);
1697 let contents = std::fs::read_to_string(&path).unwrap();
1698 assert!(!contents.contains(r#""id":"1""#));
1699 assert!(contents.contains(r#""id":"3""#));
1700 }
1701
1702 #[test]
1703 fn prune_missing_file_is_a_noop() {
1704 let dir = tempfile::tempdir().unwrap();
1705 let path = dir.path().join("absent.jsonl");
1706 let outcome = prune(
1707 &path,
1708 &PruneOptions {
1709 older_than: None,
1710 max_size: Some(1),
1711 dry_run: false,
1712 },
1713 )
1714 .unwrap();
1715 assert_eq!(outcome.removed, 0);
1716 assert_eq!(outcome.kept, 0);
1717 assert!(!path.exists());
1718 }
1719
1720 #[cfg(unix)]
1721 #[test]
1722 fn rotation_shifts_numbered_files_and_drops_the_oldest() {
1723 use std::os::unix::fs::PermissionsExt;
1724
1725 let dir = tempfile::tempdir().unwrap();
1726 let path = dir.path().join("log.jsonl");
1727 let cfg = RotationConfig {
1729 max_size: 20,
1730 keep_files: 2,
1731 };
1732
1733 let line = "0123456789012345\n"; for _ in 0..4 {
1735 append_with_rotation(&path, line, &cfg).unwrap();
1736 }
1737
1738 assert!(path.exists());
1741 assert!(sibling(&path, ".1").exists());
1742 assert!(sibling(&path, ".2").exists());
1743 assert!(!sibling(&path, ".3").exists());
1744 assert_eq!(
1746 std::fs::metadata(sibling(&path, ".1"))
1747 .unwrap()
1748 .permissions()
1749 .mode()
1750 & 0o777,
1751 0o600
1752 );
1753 }
1754
1755 #[cfg(unix)]
1756 #[test]
1757 fn rotation_keep_zero_discards_on_overflow() {
1758 let dir = tempfile::tempdir().unwrap();
1759 let path = dir.path().join("log.jsonl");
1760 let cfg = RotationConfig {
1761 max_size: 20,
1762 keep_files: 0,
1763 };
1764 let line = "0123456789012345\n"; append_with_rotation(&path, line, &cfg).unwrap();
1766 append_with_rotation(&path, line, &cfg).unwrap();
1767 assert!(!sibling(&path, ".1").exists());
1769 assert_eq!(std::fs::read_to_string(&path).unwrap(), line);
1770 }
1771
1772 #[test]
1773 fn parse_size_rejects_overflow_to_infinity() {
1774 assert!(parse_size(&"9".repeat(400)).is_err());
1776 }
1777
1778 #[test]
1779 fn prune_surfaces_a_read_error() {
1780 let dir = tempfile::tempdir().unwrap();
1783 let result = prune(
1784 dir.path(),
1785 &PruneOptions {
1786 older_than: None,
1787 max_size: Some(1),
1788 dry_run: false,
1789 },
1790 );
1791 assert!(result.is_err());
1792 }
1793
1794 #[cfg(unix)]
1795 #[test]
1796 fn append_with_rotation_appends_even_when_rotate_fails() {
1797 let dir = tempfile::tempdir().unwrap();
1798 let path = dir.path().join("log.jsonl");
1799 std::fs::write(&path, "0123456789012345\n").unwrap();
1801 std::fs::create_dir(sibling(&path, ".1")).unwrap();
1803 let cfg = RotationConfig {
1804 max_size: 5,
1805 keep_files: 1,
1806 };
1807 append_with_rotation(&path, "new-line\n", &cfg).unwrap();
1809 assert!(
1810 std::fs::read_to_string(&path).unwrap().contains("new-line"),
1811 "the record is appended despite the rotation failure"
1812 );
1813 }
1814
1815 #[test]
1816 fn prune_cleans_up_temp_on_rewrite_failure() {
1817 let dir = tempfile::tempdir().unwrap();
1818 let path = dir.path().join("log.jsonl");
1819 std::fs::write(
1820 &path,
1821 format!(
1822 "{}\n{}\n",
1823 http_line("a", "2999-01-01T00:00:00.000Z"),
1824 http_line("b", "2999-01-01T00:00:00.000Z"),
1825 ),
1826 )
1827 .unwrap();
1828 let tmp = sibling(&path, &format!(".prune.{}.tmp", std::process::id()));
1831 std::fs::create_dir(&tmp).unwrap();
1832
1833 let result = prune(
1834 &path,
1835 &PruneOptions {
1836 older_than: None,
1837 max_size: Some(1),
1838 dry_run: false,
1839 },
1840 );
1841 assert!(result.is_err(), "a failing rewrite surfaces as an error");
1842 let _ = std::fs::remove_dir(&tmp);
1843 }
1844
1845 #[tokio::test]
1846 async fn scope_origin_id_overwrites_id_but_preserves_source() {
1847 let base = RequestLogContext {
1850 invocation_id: "daemon-1".to_string(),
1851 source: Source::Daemon,
1852 mcp_tool: None,
1853 };
1854 CTX.scope(base, async {
1855 scope_origin_id("cli-42".to_string(), async {
1856 let ctx = current_context();
1857 assert_eq!(ctx.invocation_id, "cli-42");
1859 assert_eq!(ctx.source, Source::Daemon);
1861 })
1862 .await;
1863 assert_eq!(current_context().invocation_id, "daemon-1");
1865 })
1866 .await;
1867 }
1868}