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 fn disabled() -> bool {
276 env_flag("OMNI_DEV_LOG_DISABLE")
277}
278
279pub fn bodies_enabled() -> bool {
281 env_flag("OMNI_DEV_LOG_BODIES")
282}
283
284pub fn headers_enabled() -> bool {
286 env_flag("OMNI_DEV_LOG_HEADERS")
287}
288
289fn env_flag(name: &str) -> bool {
291 std::env::var(name).is_ok_and(|v| {
292 let v = v.trim().to_ascii_lowercase();
293 v == "1" || v == "true" || v == "yes"
294 })
295}
296
297pub fn log_file_path() -> Option<PathBuf> {
300 if let Ok(path) = std::env::var("OMNI_DEV_LOG_FILE") {
301 if !path.is_empty() {
302 return Some(PathBuf::from(path));
303 }
304 }
305 let base = dirs::state_dir().or_else(dirs::data_dir)?;
306 Some(base.join("omni-dev").join(LOG_FILE_NAME))
307}
308
309pub fn record(entry: &LogRecord) {
312 if disabled() {
313 return;
314 }
315 if let Err(e) = try_record(entry) {
316 tracing::debug!("request_log: failed to append record: {e}");
317 }
318}
319
320fn try_record(entry: &LogRecord) -> anyhow::Result<()> {
322 use anyhow::Context;
323
324 let path = log_file_path().context("could not resolve the log file path")?;
325 if let Some(parent) = path.parent() {
329 if !parent.as_os_str().is_empty() && !parent.exists() {
330 crate::daemon::paths::ensure_dir_0700(parent)?;
331 }
332 }
333 let mut line = serde_json::to_string(entry).context("failed to serialize record")?;
334 line.push('\n');
335 append_line(&path, &line)?;
336 Ok(())
337}
338
339#[cfg(unix)]
347fn append_line(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
348 use std::os::unix::fs::OpenOptionsExt;
349
350 if let Some(cfg) = rotation_config() {
353 return append_with_rotation(path, line, &cfg);
354 }
355
356 let file = std::fs::OpenOptions::new()
357 .append(true)
358 .create(true)
359 .mode(0o600)
360 .open(path)?;
361 crate::daemon::paths::ensure_handle_0600(&file)?;
362
363 if bodies_enabled() {
364 match nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusive) {
365 Ok(mut guard) => {
366 guard.write_all(line.as_bytes())?;
367 }
368 Err((mut file, _)) => {
369 file.write_all(line.as_bytes())?;
370 }
371 }
372 } else {
373 let mut file = file;
374 file.write_all(line.as_bytes())?;
375 }
376 Ok(())
377}
378
379#[cfg(not(unix))]
383fn append_line(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
384 let mut file = std::fs::OpenOptions::new()
385 .append(true)
386 .create(true)
387 .open(path)?;
388 file.write_all(line.as_bytes())?;
389 Ok(())
390}
391
392fn sibling(path: &Path, suffix: &str) -> PathBuf {
406 let mut name = path.as_os_str().to_owned();
407 name.push(suffix);
408 PathBuf::from(name)
409}
410
411pub(crate) fn parse_size(s: &str) -> anyhow::Result<u64> {
415 use anyhow::Context as _;
416
417 let lower = s.trim().to_ascii_lowercase();
418 if lower.is_empty() {
419 anyhow::bail!("empty size (expected e.g. 10mb, 512kb, 1048576)");
420 }
421 let split = lower
422 .find(|c: char| !c.is_ascii_digit() && c != '.')
423 .unwrap_or(lower.len());
424 let (num, unit) = lower.split_at(split);
425 let value: f64 = num
426 .parse()
427 .with_context(|| format!("invalid size number: {s}"))?;
428 if !value.is_finite() || value < 0.0 {
429 anyhow::bail!("invalid size: {s}");
430 }
431 let mult: u64 = match unit.trim() {
432 "" | "b" => 1,
433 "k" | "kb" | "kib" => 1024,
434 "m" | "mb" | "mib" => 1024 * 1024,
435 "g" | "gb" | "gib" => 1024 * 1024 * 1024,
436 other => anyhow::bail!("invalid size unit: {other} (use b, kb, mb, or gb)"),
437 };
438 Ok((value * mult as f64) as u64)
439}
440
441#[cfg(unix)]
445struct RotationConfig {
446 max_size: u64,
448 keep_files: u32,
450}
451
452#[cfg(unix)]
456fn rotation_config() -> Option<RotationConfig> {
457 let raw = std::env::var("OMNI_DEV_LOG_MAX_SIZE").ok()?;
458 if raw.trim().is_empty() {
459 return None;
460 }
461 let max_size = match parse_size(&raw) {
462 Ok(0) => return None,
463 Ok(n) => n,
464 Err(e) => {
465 tracing::debug!("request_log: ignoring invalid OMNI_DEV_LOG_MAX_SIZE: {e}");
466 return None;
467 }
468 };
469 let keep_files = std::env::var("OMNI_DEV_LOG_KEEP_FILES")
470 .ok()
471 .and_then(|v| v.trim().parse::<u32>().ok())
472 .unwrap_or(DEFAULT_KEEP_FILES);
473 Some(RotationConfig {
474 max_size,
475 keep_files,
476 })
477}
478
479#[cfg(unix)]
483fn rotate(path: &Path, keep_files: u32) -> anyhow::Result<()> {
484 if keep_files == 0 {
485 let _ = std::fs::remove_file(path);
488 return Ok(());
489 }
490 let _ = std::fs::remove_file(sibling(path, &format!(".{keep_files}")));
492 for i in (1..keep_files).rev() {
493 let from = sibling(path, &format!(".{i}"));
494 if from.exists() {
495 std::fs::rename(&from, sibling(path, &format!(".{}", i + 1)))?;
496 }
497 }
498 std::fs::rename(path, sibling(path, ".1"))?;
499 Ok(())
500}
501
502#[cfg(unix)]
508fn append_with_rotation(path: &Path, line: &str, cfg: &RotationConfig) -> anyhow::Result<()> {
509 use std::os::unix::fs::OpenOptionsExt;
510
511 let lock_path = sibling(path, ".lock");
512 let lock_file = std::fs::OpenOptions::new()
513 .create(true)
514 .write(true)
515 .truncate(false)
516 .mode(0o600)
517 .open(&lock_path)?;
518 crate::daemon::paths::ensure_handle_0600(&lock_file)?;
519 let _guard = nix::fcntl::Flock::lock(lock_file, nix::fcntl::FlockArg::LockExclusive).ok();
522
523 let current = std::fs::metadata(path).map_or(0, |m| m.len());
524 if current > 0 && current.saturating_add(line.len() as u64) > cfg.max_size {
525 if let Err(e) = rotate(path, cfg.keep_files) {
526 tracing::debug!("request_log: rotation failed, appending without rotating: {e}");
527 }
528 }
529
530 let mut file = std::fs::OpenOptions::new()
531 .append(true)
532 .create(true)
533 .mode(0o600)
534 .open(path)?;
535 crate::daemon::paths::ensure_handle_0600(&file)?;
536 file.write_all(line.as_bytes())?;
537 Ok(())
538}
539
540pub struct PruneOptions {
542 pub older_than: Option<DateTime<Utc>>,
546 pub max_size: Option<u64>,
549 pub dry_run: bool,
551}
552
553pub struct PruneOutcome {
555 pub removed: usize,
557 pub kept: usize,
559 pub bytes_before: u64,
561 pub bytes_after: u64,
563}
564
565pub fn prune(path: &Path, opts: &PruneOptions) -> anyhow::Result<PruneOutcome> {
574 use anyhow::Context as _;
575
576 let data = match std::fs::read(path) {
577 Ok(data) => data,
578 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
579 return Ok(PruneOutcome {
580 removed: 0,
581 kept: 0,
582 bytes_before: 0,
583 bytes_after: 0,
584 });
585 }
586 Err(e) => return Err(e).context("failed to read the log file"),
587 };
588 let bytes_before = data.len() as u64;
589 let text = String::from_utf8_lossy(&data);
590
591 let all: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
594 let aged: Vec<&str> = all
595 .iter()
596 .copied()
597 .filter(|line| keep_by_age(line, opts.older_than))
598 .collect();
599
600 let kept: &[&str] = match opts.max_size {
601 None => &aged,
602 Some(max) => keep_by_size(&aged, max),
603 };
604
605 let bytes_after: u64 = kept.iter().map(|l| l.len() as u64 + 1).sum();
606 let outcome = PruneOutcome {
607 removed: all.len() - kept.len(),
608 kept: kept.len(),
609 bytes_before,
610 bytes_after,
611 };
612
613 if !opts.dry_run && outcome.removed > 0 {
614 rewrite_atomically(path, kept)?;
615 }
616 Ok(outcome)
617}
618
619fn keep_by_age(line: &str, older_than: Option<DateTime<Utc>>) -> bool {
622 let Some(cutoff) = older_than else {
623 return true;
624 };
625 match serde_json::from_str::<LogRecord>(line) {
626 Ok(rec) => match DateTime::parse_from_rfc3339(&rec.timestamp) {
627 Ok(ts) => ts.with_timezone(&Utc) >= cutoff,
628 Err(_) => true,
629 },
630 Err(_) => true,
631 }
632}
633
634fn keep_by_size<'a>(lines: &'a [&'a str], max: u64) -> &'a [&'a str] {
637 let mut acc = 0u64;
638 let mut start = lines.len();
639 for (i, line) in lines.iter().enumerate().rev() {
640 acc += line.len() as u64 + 1;
641 if acc > max {
642 break;
643 }
644 start = i;
645 }
646 if start == lines.len() && !lines.is_empty() {
647 start = lines.len() - 1; }
649 &lines[start..]
650}
651
652fn rewrite_atomically(path: &Path, lines: &[&str]) -> anyhow::Result<()> {
655 let tmp = sibling(path, &format!(".prune.{}.tmp", std::process::id()));
656 let result = (|| -> anyhow::Result<()> {
657 let mut options = std::fs::OpenOptions::new();
658 options.create(true).write(true).truncate(true);
659 #[cfg(unix)]
660 {
661 use std::os::unix::fs::OpenOptionsExt;
662 options.mode(0o600);
663 }
664 let mut file = options.open(&tmp)?;
665 #[cfg(unix)]
666 crate::daemon::paths::ensure_handle_0600(&file)?;
667 for line in lines {
668 file.write_all(line.as_bytes())?;
669 file.write_all(b"\n")?;
670 }
671 file.flush()?;
672 std::fs::rename(&tmp, path)?;
673 Ok(())
674 })();
675 if result.is_err() {
676 let _ = std::fs::remove_file(&tmp);
677 }
678 result
679}
680
681#[derive(Debug, Clone)]
683pub struct InvocationOutcome {
684 pub command: Vec<String>,
686 pub command_line: Vec<String>,
688 pub exit_code: i32,
690 pub error: Option<String>,
692 pub duration: Duration,
694}
695
696pub fn record_invocation(outcome: InvocationOutcome) {
698 let ctx = current_context();
699 let mut rec = LogRecord::new(RecordKind::Invocation, ctx.invocation_id);
700 rec.source = Some(ctx.source);
701 rec.mcp_tool = ctx.mcp_tool;
702 rec.command = outcome.command;
703 rec.command_line = scrub_argv(&outcome.command_line);
704 rec.exit_code = Some(outcome.exit_code);
705 rec.error = outcome.error;
706 rec.duration_ms = Some(outcome.duration.as_millis() as u64);
707 rec.env = whitelisted_env();
708 record(&rec);
709}
710
711#[derive(Debug, Clone, Default)]
714pub struct HttpExtra {
715 pub via_daemon: bool,
717 pub daemon_session_id: Option<String>,
719 pub auth_principal: Option<String>,
721 pub request_headers: BTreeMap<String, String>,
723 pub response_headers: BTreeMap<String, String>,
725 pub request_body: Option<String>,
727 pub response_body: Option<String>,
729 pub context: BTreeMap<String, String>,
731}
732
733pub fn record_http(
735 service: &str,
736 method: &str,
737 url: &str,
738 started: Instant,
739 status: Option<u16>,
740 error: Option<&str>,
741) {
742 record_http_with(
743 service,
744 method,
745 url,
746 started,
747 status,
748 error,
749 HttpExtra::default(),
750 );
751}
752
753pub fn record_http_result(
759 service: &str,
760 method: &str,
761 url: &str,
762 started: Instant,
763 result: &reqwest::Result<reqwest::Response>,
764) {
765 match result {
766 Ok(response) => {
767 record_http(
768 service,
769 method,
770 url,
771 started,
772 Some(response.status().as_u16()),
773 None,
774 );
775 }
776 Err(error) => {
777 record_http(
778 service,
779 method,
780 url,
781 started,
782 None,
783 Some(&error.to_string()),
784 );
785 }
786 }
787}
788
789#[allow(clippy::too_many_arguments)]
796pub fn record_http_with(
797 service: &str,
798 method: &str,
799 url: &str,
800 started: Instant,
801 status: Option<u16>,
802 error: Option<&str>,
803 extra: HttpExtra,
804) {
805 if disabled() {
806 return;
807 }
808 let ctx = current_context();
809 let mut rec = LogRecord::new(RecordKind::Http, ctx.invocation_id);
810 rec.source = Some(ctx.source);
811 rec.mcp_tool = ctx.mcp_tool;
812 rec.service = Some(service.to_string());
813 rec.method = Some(method.to_string());
814 rec.url = Some(redact_url(url));
815 rec.status_code = status;
816 rec.elapsed_ms = Some(started.elapsed().as_millis() as u64);
817 rec.error = error.map(str::to_string);
818 rec.via_daemon = extra.via_daemon;
819 rec.daemon_session_id = extra.daemon_session_id;
820 rec.auth_principal = extra.auth_principal;
821 rec.context = extra.context;
822 if headers_enabled() {
823 rec.request_headers = redact_headers(&extra.request_headers);
824 rec.response_headers = redact_headers(&extra.response_headers);
825 }
826 if bodies_enabled() {
827 rec.request_body = extra.request_body;
828 rec.response_body = extra.response_body;
829 }
830 record(&rec);
831}
832
833const SENSITIVE_HEADERS: &[&str] = &[
835 "authorization",
836 "proxy-authorization",
837 "cookie",
838 "set-cookie",
839 "x-api-key",
840 "api-key",
841 "dd-api-key",
842 "dd-application-key",
843 "x-datadog-api-key",
844 "x-datadog-application-key",
845 "x-omni-bridge",
846 "x-omni-bridge-target",
847];
848
849const SENSITIVE_HEADER_MARKERS: &[&str] = &[
853 "auth",
854 "token",
855 "secret",
856 "key",
857 "cookie",
858 "password",
859 "session",
860 "signature",
861 "credential",
862];
863
864pub fn redact_headers(headers: &BTreeMap<String, String>) -> BTreeMap<String, String> {
869 headers
870 .iter()
871 .map(|(name, value)| {
872 let lower = name.to_ascii_lowercase();
873 let redacted = SENSITIVE_HEADERS.contains(&lower.as_str())
874 || SENSITIVE_HEADER_MARKERS
875 .iter()
876 .any(|marker| lower.contains(marker));
877 (
878 name.clone(),
879 if redacted {
880 "REDACTED".to_string()
881 } else {
882 value.clone()
883 },
884 )
885 })
886 .collect()
887}
888
889const SECRETISH_FLAG_WORDS: &[&str] = &["token", "secret", "password", "passwd", "key"];
893
894fn is_secretish_flag(name: &str) -> bool {
898 let segments: Vec<String> = name
899 .split(['-', '_'])
900 .map(str::to_ascii_lowercase)
901 .collect();
902 let takes_path = matches!(segments.last().map(String::as_str), Some("file" | "path"));
903 !takes_path
904 && segments
905 .iter()
906 .any(|segment| SECRETISH_FLAG_WORDS.contains(&segment.as_str()))
907}
908
909fn scrub_header_arg(value: &str) -> Option<String> {
913 let Some((name, _)) = value.split_once(':') else {
914 return Some("REDACTED".to_string());
915 };
916 SENSITIVE_HEADERS
917 .contains(&name.trim().to_ascii_lowercase().as_str())
918 .then(|| format!("{}: REDACTED", name.trim()))
919}
920
921fn scrub_flag_value(name: &str, value: &str) -> Option<String> {
925 match name {
926 "header" => scrub_header_arg(value),
927 "body" => (!value.starts_with('@')).then(|| "REDACTED".to_string()),
928 _ if is_secretish_flag(name) => Some("REDACTED".to_string()),
929 _ => None,
930 }
931}
932
933fn scrub_argv(argv: &[String]) -> Vec<String> {
945 scrub_flag_secrets(argv)
946 .iter()
947 .map(|arg| redact_url(arg))
948 .collect()
949}
950
951fn scrub_flag_secrets(argv: &[String]) -> Vec<String> {
956 let mut out = Vec::with_capacity(argv.len());
957 let mut i = 0;
958 while i < argv.len() {
959 let arg = &argv[i];
960 i += 1;
961 let Some(flag_body) = arg.strip_prefix("--") else {
962 out.push(arg.clone());
963 continue;
964 };
965 if let Some((name, value)) = flag_body.split_once('=') {
966 match scrub_flag_value(name, value) {
967 Some(scrubbed) => out.push(format!("--{name}={scrubbed}")),
968 None => out.push(arg.clone()),
969 }
970 } else {
971 out.push(arg.clone());
972 let takes_secret_value =
973 matches!(flag_body, "header" | "body") || is_secretish_flag(flag_body);
974 if takes_secret_value {
975 if let Some(value) = argv.get(i) {
976 i += 1;
977 out.push(scrub_flag_value(flag_body, value).unwrap_or_else(|| value.clone()));
978 }
979 }
980 }
981 }
982 out
983}
984
985const SENSITIVE_QUERY_KEYS: &[&str] = &["sig", "sas", "jwt", "auth"];
987
988const SENSITIVE_QUERY_KEY_SUFFIXES: &[&str] = &[
991 "token",
992 "secret",
993 "password",
994 "passwd",
995 "signature",
996 "apikey",
997 "api_key",
998 "api-key",
999];
1000
1001const SENSITIVE_QUERY_KEY_PREFIXES: &[&str] = &["x-amz-", "x-goog-"];
1003
1004fn sensitive_query_key(key: &str) -> bool {
1006 let key = key.to_ascii_lowercase();
1007 SENSITIVE_QUERY_KEYS.contains(&key.as_str())
1008 || SENSITIVE_QUERY_KEY_SUFFIXES
1009 .iter()
1010 .any(|suffix| key.ends_with(suffix))
1011 || SENSITIVE_QUERY_KEY_PREFIXES
1012 .iter()
1013 .any(|prefix| key.starts_with(prefix))
1014}
1015
1016fn redact_pairs(pairs: &str) -> String {
1020 pairs
1021 .split('&')
1022 .map(|segment| match segment.split_once('=') {
1023 Some((raw_key, _)) => {
1024 let sensitive = url::form_urlencoded::parse(raw_key.as_bytes())
1027 .next()
1028 .is_some_and(|(key, _)| sensitive_query_key(&key));
1029 if sensitive {
1030 format!("{raw_key}=REDACTED")
1031 } else {
1032 segment.to_string()
1033 }
1034 }
1035 None => segment.to_string(),
1037 })
1038 .collect::<Vec<_>>()
1039 .join("&")
1040}
1041
1042fn redact_url(url: &str) -> String {
1048 let (rest, fragment) = url
1049 .split_once('#')
1050 .map_or((url, None), |(rest, fragment)| (rest, Some(fragment)));
1051 let (prefix, query) = rest
1052 .split_once('?')
1053 .map_or((rest, None), |(prefix, query)| (prefix, Some(query)));
1054 let mut out = prefix.to_string();
1055 if let Some(query) = query {
1056 out.push('?');
1057 out.push_str(&redact_pairs(query));
1058 }
1059 if let Some(fragment) = fragment {
1060 out.push('#');
1061 out.push_str(&redact_pairs(fragment));
1062 }
1063 out
1064}
1065
1066pub fn new_id() -> String {
1072 let millis = chrono::Utc::now().timestamp_millis().max(0);
1073 let suffix = rand::random::<u64>();
1074 format!("{millis:013}-{suffix:016x}")
1075}
1076
1077fn now_rfc3339_millis() -> String {
1079 chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
1080}
1081
1082fn cwd() -> String {
1084 std::env::current_dir()
1085 .map(|p| p.display().to_string())
1086 .unwrap_or_default()
1087}
1088
1089fn system_user() -> String {
1091 if let Ok(user) = std::env::var("USER") {
1092 if !user.is_empty() {
1093 return user;
1094 }
1095 }
1096 #[cfg(unix)]
1097 {
1098 if let Ok(Some(user)) = nix::unistd::User::from_uid(nix::unistd::geteuid()) {
1099 return user.name;
1100 }
1101 }
1102 String::new()
1103}
1104
1105fn hostname() -> String {
1107 #[cfg(unix)]
1108 {
1109 if let Ok(name) = nix::unistd::gethostname() {
1110 if let Some(name) = name.to_str() {
1111 if !name.is_empty() {
1112 return name.to_string();
1113 }
1114 }
1115 }
1116 }
1117 std::env::var("HOSTNAME").unwrap_or_default()
1118}
1119
1120const SECRETISH: &[&str] = &["TOKEN", "SECRET", "KEY", "PASSWORD", "PASSWD"];
1123
1124fn whitelisted_env() -> BTreeMap<String, String> {
1126 std::env::vars()
1127 .filter(|(k, _)| k.starts_with("OMNI_DEV_"))
1128 .map(|(k, v)| {
1129 let secretish = SECRETISH.iter().any(|needle| k.contains(needle));
1130 let value = if secretish { "REDACTED".to_string() } else { v };
1131 (k, value)
1132 })
1133 .collect()
1134}
1135
1136#[cfg(test)]
1137#[allow(clippy::unwrap_used, clippy::expect_used)]
1138mod tests {
1139 use super::*;
1140
1141 #[test]
1142 fn record_round_trips_through_json() {
1143 let mut rec = LogRecord::new(RecordKind::Http, "inv-1".to_string());
1144 rec.service = Some("jira".to_string());
1145 rec.method = Some("GET".to_string());
1146 rec.url = Some("https://example.atlassian.net/rest/api/3/issue/X-1".to_string());
1147 rec.status_code = Some(200);
1148 rec.elapsed_ms = Some(42);
1149
1150 let line = serde_json::to_string(&rec).unwrap();
1151 let back: LogRecord = serde_json::from_str(&line).unwrap();
1152 assert_eq!(back.invocation_id, "inv-1");
1153 assert_eq!(back.kind, RecordKind::Http);
1154 assert_eq!(back.service.as_deref(), Some("jira"));
1155 assert_eq!(back.status_code, Some(200));
1156 }
1157
1158 #[test]
1159 fn reader_tolerates_unknown_fields() {
1160 let line = r#"{"id":"x","invocation_id":"i","kind":"http","method":"GET",
1161 "future_field":{"nested":true},"another":42}"#;
1162 let rec: LogRecord = serde_json::from_str(line).unwrap();
1163 assert_eq!(rec.kind, RecordKind::Http);
1164 assert_eq!(rec.method.as_deref(), Some("GET"));
1165 }
1166
1167 #[test]
1168 fn reader_tolerates_missing_newer_fields() {
1169 let line = r#"{"kind":"invocation","command":["git","view"]}"#;
1171 let rec: LogRecord = serde_json::from_str(line).unwrap();
1172 assert_eq!(rec.kind, RecordKind::Invocation);
1173 assert_eq!(rec.command, vec!["git", "view"]);
1174 assert!(rec.status_code.is_none());
1175 assert!(rec.id.is_empty());
1176 }
1177
1178 #[test]
1179 fn unknown_kind_and_source_do_not_fail() {
1180 let line = r#"{"kind":"telemetry","source":"webhook"}"#;
1181 let rec: LogRecord = serde_json::from_str(line).unwrap();
1182 assert_eq!(rec.kind, RecordKind::Unknown);
1183 assert_eq!(rec.source, Some(Source::Unknown));
1184 }
1185
1186 #[test]
1187 fn optional_fields_are_skipped_when_empty() {
1188 let rec = LogRecord::new(RecordKind::Invocation, "i".to_string());
1189 let line = serde_json::to_string(&rec).unwrap();
1190 assert!(!line.contains("status_code"));
1192 assert!(!line.contains("request_headers"));
1193 assert!(!line.contains("via_daemon"));
1194 assert!(!line.contains("\"env\""));
1195 }
1196
1197 #[test]
1198 fn ids_are_time_sortable() {
1199 let a = new_id();
1200 std::thread::sleep(std::time::Duration::from_millis(2));
1201 let b = new_id();
1202 assert!(a < b, "{a} should sort before {b}");
1203 }
1204
1205 #[test]
1206 fn sensitive_headers_are_redacted() {
1207 let mut headers = BTreeMap::new();
1208 headers.insert("Authorization".to_string(), "Bearer secret".to_string());
1209 headers.insert("X-Api-Key".to_string(), "abc123".to_string());
1210 headers.insert("Content-Type".to_string(), "application/json".to_string());
1211 let out = redact_headers(&headers);
1212 assert_eq!(out["Authorization"], "REDACTED");
1213 assert_eq!(out["X-Api-Key"], "REDACTED");
1214 assert_eq!(out["Content-Type"], "application/json");
1215 }
1216
1217 fn argv(args: &[&str]) -> Vec<String> {
1218 args.iter().copied().map(String::from).collect()
1219 }
1220
1221 #[test]
1222 fn scrub_argv_redacts_sensitive_header_in_both_forms() {
1223 let out = scrub_argv(&argv(&[
1224 "omni-dev",
1225 "--header",
1226 "Authorization: Bearer sekret",
1227 "--header=Cookie: session=abc",
1228 ]));
1229 assert_eq!(
1230 out,
1231 argv(&[
1232 "omni-dev",
1233 "--header",
1234 "Authorization: REDACTED",
1235 "--header=Cookie: REDACTED",
1236 ])
1237 );
1238 }
1239
1240 #[test]
1241 fn scrub_argv_keeps_non_sensitive_headers() {
1242 let input = argv(&["omni-dev", "--header", "Content-Type: application/json"]);
1243 assert_eq!(scrub_argv(&input), input);
1244 }
1245
1246 #[test]
1247 fn scrub_argv_redacts_colonless_header_wholesale() {
1248 let out = scrub_argv(&argv(&["omni-dev", "--header", "sekret"]));
1249 assert_eq!(out, argv(&["omni-dev", "--header", "REDACTED"]));
1250 }
1251
1252 #[test]
1253 fn scrub_argv_redacts_inline_body_but_keeps_at_file() {
1254 let out = scrub_argv(&argv(&["omni-dev", "--body", r#"{"secret":1}"#]));
1255 assert_eq!(out, argv(&["omni-dev", "--body", "REDACTED"]));
1256
1257 let file_form = argv(&["omni-dev", "--body", "@payload.json"]);
1258 assert_eq!(scrub_argv(&file_form), file_form);
1259
1260 let out = scrub_argv(&argv(&["omni-dev", "--body=sekret"]));
1261 assert_eq!(out, argv(&["omni-dev", "--body=REDACTED"]));
1262 }
1263
1264 #[test]
1265 fn scrub_argv_redacts_secretish_flag_values() {
1266 let out = scrub_argv(&argv(&["omni-dev", "--api-key", "abc", "--auth-token=xyz"]));
1267 assert_eq!(
1268 out,
1269 argv(&["omni-dev", "--api-key", "REDACTED", "--auth-token=REDACTED"])
1270 );
1271 }
1272
1273 #[test]
1274 fn scrub_argv_exempts_path_flags_and_positionals() {
1275 let input = argv(&["omni-dev", "--token-file", "/tmp/t", "PROJ-123"]);
1276 assert_eq!(scrub_argv(&input), input);
1277 }
1278
1279 #[test]
1280 fn scrub_argv_redacts_secret_bearing_url_query_in_both_forms() {
1281 let space = scrub_argv(&argv(&[
1285 "omni-dev",
1286 "browser",
1287 "bridge",
1288 "request",
1289 "--url",
1290 "/api/export?access_token=hunter2&sig=deadbeef&page=3",
1291 ]));
1292 assert_eq!(
1293 *space.last().unwrap(),
1294 "/api/export?access_token=REDACTED&sig=REDACTED&page=3"
1295 );
1296
1297 let eq_form = scrub_argv(&argv(&[
1298 "omni-dev",
1299 "--url=/api/export?access_token=hunter2&page=3",
1300 ]));
1301 assert_eq!(
1302 *eq_form.last().unwrap(),
1303 "--url=/api/export?access_token=REDACTED&page=3"
1304 );
1305
1306 let positional = scrub_argv(&argv(&["omni-dev", "https://h/cb#id_token=xyz"]));
1307 assert_eq!(
1308 *positional.last().unwrap(),
1309 "https://h/cb#id_token=REDACTED"
1310 );
1311 }
1312
1313 #[test]
1314 fn scrub_argv_leaves_benign_argv_byte_identical() {
1315 let input = argv(&[
1316 "omni-dev",
1317 "browser",
1318 "bridge",
1319 "request",
1320 "--control-port",
1321 "19998",
1322 "--url",
1323 "/api/export?page=3&sort=asc",
1324 ]);
1325 assert_eq!(scrub_argv(&input), input);
1326 }
1327
1328 #[test]
1329 fn scrub_argv_handles_trailing_flag_without_value() {
1330 let input = argv(&["omni-dev", "--body"]);
1331 assert_eq!(scrub_argv(&input), input);
1332 }
1333
1334 #[cfg(unix)]
1335 #[test]
1336 fn append_line_creates_file_owner_only() {
1337 use std::os::unix::fs::PermissionsExt;
1338 let dir = tempfile::tempdir().unwrap();
1339 let path = dir.path().join("log.jsonl");
1340 append_line(&path, "{\"kind\":\"http\"}\n").unwrap();
1341 assert_eq!(
1342 std::fs::read_to_string(&path).unwrap(),
1343 "{\"kind\":\"http\"}\n"
1344 );
1345 assert_eq!(
1346 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1347 0o600
1348 );
1349 }
1350
1351 #[cfg(unix)]
1352 #[test]
1353 fn append_line_retightens_preexisting_loose_file() {
1354 use std::os::unix::fs::PermissionsExt;
1355 let dir = tempfile::tempdir().unwrap();
1356 let path = dir.path().join("log.jsonl");
1357 std::fs::write(&path, "old\n").unwrap();
1358 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1359 append_line(&path, "new\n").unwrap();
1360 assert_eq!(std::fs::read_to_string(&path).unwrap(), "old\nnew\n");
1361 assert_eq!(
1362 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1363 0o600
1364 );
1365 }
1366
1367 #[test]
1368 fn off_list_secretish_headers_are_redacted() {
1369 let mut headers = BTreeMap::new();
1370 for name in [
1371 "X-Auth-Token",
1372 "x-amz-security-token",
1373 "X-Goog-Api-Key",
1374 "x-csrf-token",
1375 "X-Vendor-Token",
1376 "X-Omni-Bridge",
1377 ] {
1378 headers.insert(name.to_string(), "secret-value".to_string());
1379 }
1380 for name in [
1381 "Content-Type",
1382 "Accept",
1383 "User-Agent",
1384 "x-request-id",
1385 "traceparent",
1386 ] {
1387 headers.insert(name.to_string(), "plain-value".to_string());
1388 }
1389 let out = redact_headers(&headers);
1390 assert_eq!(out["X-Auth-Token"], "REDACTED");
1391 assert_eq!(out["x-amz-security-token"], "REDACTED");
1392 assert_eq!(out["X-Goog-Api-Key"], "REDACTED");
1393 assert_eq!(out["x-csrf-token"], "REDACTED");
1394 assert_eq!(out["X-Vendor-Token"], "REDACTED");
1395 assert_eq!(out["X-Omni-Bridge"], "REDACTED");
1396 assert_eq!(out["Content-Type"], "plain-value");
1397 assert_eq!(out["Accept"], "plain-value");
1398 assert_eq!(out["User-Agent"], "plain-value");
1399 assert_eq!(out["x-request-id"], "plain-value");
1400 assert_eq!(out["traceparent"], "plain-value");
1401 }
1402
1403 #[test]
1404 fn url_without_query_is_unchanged() {
1405 assert_eq!(redact_url("https://h/p"), "https://h/p");
1406 assert_eq!(redact_url("/relative/p"), "/relative/p");
1407 }
1408
1409 #[test]
1410 fn benign_query_is_byte_identical() {
1411 let url = "https://h/p?q=a%20b&page=2&&x=y+z&keyword=k&sort_key=s&token_type=bearer";
1412 assert_eq!(redact_url(url), url);
1413 }
1414
1415 #[test]
1416 fn sensitive_query_values_are_redacted() {
1417 let url = "https://h/p?token=a&access_token=b&client_secret=c&api_key=d&x=1";
1418 assert_eq!(
1419 redact_url(url),
1420 "https://h/p?token=REDACTED&access_token=REDACTED&client_secret=REDACTED\
1421 &api_key=REDACTED&x=1"
1422 );
1423 }
1424
1425 #[test]
1426 fn presigned_s3_query_is_redacted() {
1427 let url = "https://bucket.s3.amazonaws.com/key?X-Amz-Algorithm=AWS4-HMAC-SHA256\
1428 &X-Amz-Credential=AKIA%2F20260703%2Fus-east-1%2Fs3%2Faws4_request\
1429 &X-Amz-Date=20260703T000000Z&X-Amz-Expires=3600\
1430 &X-Amz-SignedHeaders=host&X-Amz-Signature=deadbeef";
1431 assert_eq!(
1432 redact_url(url),
1433 "https://bucket.s3.amazonaws.com/key?X-Amz-Algorithm=REDACTED\
1434 &X-Amz-Credential=REDACTED&X-Amz-Date=REDACTED&X-Amz-Expires=REDACTED\
1435 &X-Amz-SignedHeaders=REDACTED&X-Amz-Signature=REDACTED"
1436 );
1437 }
1438
1439 #[test]
1440 fn key_matching_is_case_insensitive() {
1441 assert_eq!(
1442 redact_url("/p?TOKEN=x&Api_Key=y&X-Amz-Signature=z"),
1443 "/p?TOKEN=REDACTED&Api_Key=REDACTED&X-Amz-Signature=REDACTED"
1444 );
1445 }
1446
1447 #[test]
1448 fn repeated_sensitive_keys_are_each_redacted() {
1449 assert_eq!(redact_url("/p?sig=a&sig=b"), "/p?sig=REDACTED&sig=REDACTED");
1450 }
1451
1452 #[test]
1453 fn valueless_key_is_left_alone() {
1454 assert_eq!(redact_url("/p?token"), "/p?token");
1455 assert_eq!(redact_url("/p?token="), "/p?token=REDACTED");
1456 }
1457
1458 #[test]
1459 fn relative_url_query_is_redacted() {
1460 assert_eq!(
1461 redact_url("/api/foo?sig=abc&x=y"),
1462 "/api/foo?sig=REDACTED&x=y"
1463 );
1464 }
1465
1466 #[test]
1467 fn fragment_credentials_are_redacted() {
1468 assert_eq!(
1469 redact_url("https://h/cb#access_token=xyz&token_type=bearer"),
1470 "https://h/cb#access_token=REDACTED&token_type=bearer"
1471 );
1472 }
1473
1474 #[test]
1475 fn query_and_fragment_are_scrubbed_independently() {
1476 assert_eq!(
1477 redact_url("/p?sig=a#id_token=b"),
1478 "/p?sig=REDACTED#id_token=REDACTED"
1479 );
1480 }
1481
1482 #[test]
1483 fn question_mark_in_fragment_is_not_parsed_as_query() {
1484 assert_eq!(
1488 redact_url("https://h/p#frag?token=x"),
1489 "https://h/p#frag?token=REDACTED"
1490 );
1491 }
1492
1493 #[test]
1494 fn encoded_sensitive_key_is_decoded_before_matching() {
1495 assert_eq!(
1496 redact_url("/p?access%5Ftoken=v"),
1497 "/p?access%5Ftoken=REDACTED"
1498 );
1499 }
1500
1501 #[test]
1502 fn empty_query_is_unchanged() {
1503 assert_eq!(redact_url("https://h/p?"), "https://h/p?");
1504 assert_eq!(redact_url("https://h/p?#f"), "https://h/p?#f");
1505 }
1506
1507 #[test]
1508 fn env_flag_parses_truthy_values() {
1509 std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "1");
1510 assert!(env_flag("OMNI_DEV_TEST_FLAG_ABC"));
1511 std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "TRUE");
1512 assert!(env_flag("OMNI_DEV_TEST_FLAG_ABC"));
1513 std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "0");
1514 assert!(!env_flag("OMNI_DEV_TEST_FLAG_ABC"));
1515 std::env::remove_var("OMNI_DEV_TEST_FLAG_ABC");
1516 assert!(!env_flag("OMNI_DEV_TEST_FLAG_ABC"));
1517 }
1518
1519 #[test]
1520 fn parse_size_handles_units_and_bare_bytes() {
1521 assert_eq!(parse_size("1048576").unwrap(), 1024 * 1024);
1522 assert_eq!(parse_size("512b").unwrap(), 512);
1523 assert_eq!(parse_size("10kb").unwrap(), 10 * 1024);
1524 assert_eq!(parse_size("2K").unwrap(), 2 * 1024);
1525 assert_eq!(parse_size("3mb").unwrap(), 3 * 1024 * 1024);
1526 assert_eq!(parse_size("1gb").unwrap(), 1024 * 1024 * 1024);
1527 assert_eq!(parse_size("1.5mb").unwrap(), (1.5 * 1024.0 * 1024.0) as u64);
1528 assert_eq!(parse_size(" 4mib ").unwrap(), 4 * 1024 * 1024);
1529 }
1530
1531 #[test]
1532 fn parse_size_rejects_garbage() {
1533 assert!(parse_size("").is_err());
1534 assert!(parse_size("mb").is_err());
1535 assert!(parse_size("10tb").is_err());
1536 assert!(parse_size("-5mb").is_err());
1537 }
1538
1539 #[test]
1540 fn sibling_appends_to_final_component() {
1541 let base = Path::new("/tmp/omni/log.jsonl");
1542 assert_eq!(sibling(base, ".1"), Path::new("/tmp/omni/log.jsonl.1"));
1543 assert_eq!(
1544 sibling(base, ".lock"),
1545 Path::new("/tmp/omni/log.jsonl.lock")
1546 );
1547 }
1548
1549 #[test]
1550 fn keep_by_size_keeps_most_recent_that_fit() {
1551 let lines = ["aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc", "dddddddddd"];
1553 let refs: Vec<&str> = lines.to_vec();
1554
1555 assert_eq!(keep_by_size(&refs, 22), &["cccccccccc", "dddddddddd"]);
1557 assert_eq!(keep_by_size(&refs, 1), &["dddddddddd"]);
1559 assert_eq!(keep_by_size(&refs, 10_000), &refs[..]);
1561 assert!(keep_by_size(&[], 100).is_empty());
1563 }
1564
1565 #[test]
1566 fn keep_by_age_is_conservative_on_undateable_lines() {
1567 let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
1568 .unwrap()
1569 .with_timezone(&Utc);
1570 let old = r#"{"kind":"http","timestamp":"2026-01-01T00:00:00.000Z"}"#;
1571 let new = r#"{"kind":"http","timestamp":"2026-12-01T00:00:00.000Z"}"#;
1572 let undated = r#"{"kind":"http"}"#;
1573 let malformed = "not json at all";
1574
1575 assert!(!keep_by_age(old, Some(cutoff)));
1576 assert!(keep_by_age(new, Some(cutoff)));
1577 assert!(keep_by_age(undated, Some(cutoff)), "undated is kept");
1578 assert!(keep_by_age(malformed, Some(cutoff)), "malformed is kept");
1579 assert!(keep_by_age(old, None), "no filter keeps everything");
1580 }
1581
1582 fn http_line(id: &str, ts: &str) -> String {
1583 format!(r#"{{"id":"{id}","kind":"http","timestamp":"{ts}"}}"#)
1584 }
1585
1586 #[test]
1587 fn prune_by_age_drops_old_records_and_rewrites_atomically() {
1588 use std::os::unix::fs::PermissionsExt;
1589
1590 let dir = tempfile::tempdir().unwrap();
1591 let path = dir.path().join("log.jsonl");
1592 let body = format!(
1593 "{}\n{}\n{}\n",
1594 http_line("1", "2026-01-01T00:00:00.000Z"),
1595 http_line("2", "2026-06-15T00:00:00.000Z"),
1596 http_line("3", "2026-12-31T00:00:00.000Z"),
1597 );
1598 std::fs::write(&path, &body).unwrap();
1599 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1600
1601 let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
1602 .unwrap()
1603 .with_timezone(&Utc);
1604 let outcome = prune(
1605 &path,
1606 &PruneOptions {
1607 older_than: Some(cutoff),
1608 max_size: None,
1609 dry_run: false,
1610 },
1611 )
1612 .unwrap();
1613
1614 assert_eq!(outcome.removed, 1);
1615 assert_eq!(outcome.kept, 2);
1616 let contents = std::fs::read_to_string(&path).unwrap();
1617 assert!(!contents.contains(r#""id":"1""#));
1618 assert!(contents.contains(r#""id":"2""#));
1619 assert!(contents.contains(r#""id":"3""#));
1620 assert_eq!(
1622 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1623 0o600
1624 );
1625 }
1626
1627 #[test]
1628 fn prune_dry_run_reports_without_modifying() {
1629 let dir = tempfile::tempdir().unwrap();
1630 let path = dir.path().join("log.jsonl");
1631 let body = format!(
1632 "{}\n{}\n",
1633 http_line("1", "2026-01-01T00:00:00.000Z"),
1634 http_line("2", "2026-12-31T00:00:00.000Z"),
1635 );
1636 std::fs::write(&path, &body).unwrap();
1637
1638 let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
1639 .unwrap()
1640 .with_timezone(&Utc);
1641 let outcome = prune(
1642 &path,
1643 &PruneOptions {
1644 older_than: Some(cutoff),
1645 max_size: None,
1646 dry_run: true,
1647 },
1648 )
1649 .unwrap();
1650
1651 assert_eq!(outcome.removed, 1);
1652 assert_eq!(std::fs::read_to_string(&path).unwrap(), body);
1654 }
1655
1656 #[test]
1657 fn prune_by_size_keeps_the_newest_that_fit() {
1658 let dir = tempfile::tempdir().unwrap();
1659 let path = dir.path().join("log.jsonl");
1660 let l1 = http_line("1", "2026-01-01T00:00:00.000Z");
1661 let l2 = http_line("2", "2026-06-15T00:00:00.000Z");
1662 let l3 = http_line("3", "2026-12-31T00:00:00.000Z");
1663 std::fs::write(&path, format!("{l1}\n{l2}\n{l3}\n")).unwrap();
1664
1665 let budget = (l2.len() + 1 + l3.len() + 1) as u64;
1667 let outcome = prune(
1668 &path,
1669 &PruneOptions {
1670 older_than: None,
1671 max_size: Some(budget),
1672 dry_run: false,
1673 },
1674 )
1675 .unwrap();
1676
1677 assert_eq!(outcome.removed, 1);
1678 assert_eq!(outcome.kept, 2);
1679 let contents = std::fs::read_to_string(&path).unwrap();
1680 assert!(!contents.contains(r#""id":"1""#));
1681 assert!(contents.contains(r#""id":"3""#));
1682 }
1683
1684 #[test]
1685 fn prune_missing_file_is_a_noop() {
1686 let dir = tempfile::tempdir().unwrap();
1687 let path = dir.path().join("absent.jsonl");
1688 let outcome = prune(
1689 &path,
1690 &PruneOptions {
1691 older_than: None,
1692 max_size: Some(1),
1693 dry_run: false,
1694 },
1695 )
1696 .unwrap();
1697 assert_eq!(outcome.removed, 0);
1698 assert_eq!(outcome.kept, 0);
1699 assert!(!path.exists());
1700 }
1701
1702 #[cfg(unix)]
1703 #[test]
1704 fn rotation_shifts_numbered_files_and_drops_the_oldest() {
1705 use std::os::unix::fs::PermissionsExt;
1706
1707 let dir = tempfile::tempdir().unwrap();
1708 let path = dir.path().join("log.jsonl");
1709 let cfg = RotationConfig {
1711 max_size: 20,
1712 keep_files: 2,
1713 };
1714
1715 let line = "0123456789012345\n"; for _ in 0..4 {
1717 append_with_rotation(&path, line, &cfg).unwrap();
1718 }
1719
1720 assert!(path.exists());
1723 assert!(sibling(&path, ".1").exists());
1724 assert!(sibling(&path, ".2").exists());
1725 assert!(!sibling(&path, ".3").exists());
1726 assert_eq!(
1728 std::fs::metadata(sibling(&path, ".1"))
1729 .unwrap()
1730 .permissions()
1731 .mode()
1732 & 0o777,
1733 0o600
1734 );
1735 }
1736
1737 #[cfg(unix)]
1738 #[test]
1739 fn rotation_keep_zero_discards_on_overflow() {
1740 let dir = tempfile::tempdir().unwrap();
1741 let path = dir.path().join("log.jsonl");
1742 let cfg = RotationConfig {
1743 max_size: 20,
1744 keep_files: 0,
1745 };
1746 let line = "0123456789012345\n"; append_with_rotation(&path, line, &cfg).unwrap();
1748 append_with_rotation(&path, line, &cfg).unwrap();
1749 assert!(!sibling(&path, ".1").exists());
1751 assert_eq!(std::fs::read_to_string(&path).unwrap(), line);
1752 }
1753
1754 #[test]
1755 fn parse_size_rejects_overflow_to_infinity() {
1756 assert!(parse_size(&"9".repeat(400)).is_err());
1758 }
1759
1760 #[test]
1761 fn prune_surfaces_a_read_error() {
1762 let dir = tempfile::tempdir().unwrap();
1765 let result = prune(
1766 dir.path(),
1767 &PruneOptions {
1768 older_than: None,
1769 max_size: Some(1),
1770 dry_run: false,
1771 },
1772 );
1773 assert!(result.is_err());
1774 }
1775
1776 #[cfg(unix)]
1777 #[test]
1778 fn append_with_rotation_appends_even_when_rotate_fails() {
1779 let dir = tempfile::tempdir().unwrap();
1780 let path = dir.path().join("log.jsonl");
1781 std::fs::write(&path, "0123456789012345\n").unwrap();
1783 std::fs::create_dir(sibling(&path, ".1")).unwrap();
1785 let cfg = RotationConfig {
1786 max_size: 5,
1787 keep_files: 1,
1788 };
1789 append_with_rotation(&path, "new-line\n", &cfg).unwrap();
1791 assert!(
1792 std::fs::read_to_string(&path).unwrap().contains("new-line"),
1793 "the record is appended despite the rotation failure"
1794 );
1795 }
1796
1797 #[test]
1798 fn prune_cleans_up_temp_on_rewrite_failure() {
1799 let dir = tempfile::tempdir().unwrap();
1800 let path = dir.path().join("log.jsonl");
1801 std::fs::write(
1802 &path,
1803 format!(
1804 "{}\n{}\n",
1805 http_line("a", "2999-01-01T00:00:00.000Z"),
1806 http_line("b", "2999-01-01T00:00:00.000Z"),
1807 ),
1808 )
1809 .unwrap();
1810 let tmp = sibling(&path, &format!(".prune.{}.tmp", std::process::id()));
1813 std::fs::create_dir(&tmp).unwrap();
1814
1815 let result = prune(
1816 &path,
1817 &PruneOptions {
1818 older_than: None,
1819 max_size: Some(1),
1820 dry_run: false,
1821 },
1822 );
1823 assert!(result.is_err(), "a failing rewrite surfaces as an error");
1824 let _ = std::fs::remove_dir(&tmp);
1825 }
1826}