trusty_memory/prompt_log.rs
1//! Enriched-prompt logger for the UserPromptSubmit / SessionStart hooks
2//! (issue #105).
3//!
4//! Why: `trusty-memory prompt-context` and `trusty-memory inbox-check` both
5//! inject context into Claude Code sessions. Without a record of what was
6//! injected we can't evaluate the effectiveness of either pipeline (relevance,
7//! length, signal-vs-noise) or iterate on the recall / message-surfacing
8//! logic. This module captures every invocation as a single JSONL entry under
9//! the daemon data root so the logs are grep- and `jq`-friendly.
10//!
11//! What: a small, self-contained rolling writer. `PromptLogger::from_env`
12//! reads the [`PromptLogConfig`] env vars, computes the active log path, and
13//! returns a logger that swallows every I/O failure (best-effort by contract
14//! — the hook caller must never fail because of a log write). The on-disk
15//! layout is `<data_root>/logs/enriched-prompts.<YYYY-MM-DD>.jsonl` with a
16//! `.<n>.jsonl` numeric suffix appended on size-cap rotation
17//! (`enriched-prompts.2026-05-25.1.jsonl`, `.2.jsonl`, …).
18//!
19//! Rotation rules:
20//! - **Daily**: the date prefix in the filename changes when the local clock
21//! rolls over to a new UTC day.
22//! - **Size cap**: before each write, the active file's length is checked
23//! against `max_bytes` (default 50 MiB). When the cap would be exceeded
24//! the writer advances to the next numeric suffix.
25//!
26//! Retention: each successful first-write-of-the-day prunes files outside the
27//! configured window (`retention_days`, default 30). The check is cheap (one
28//! `read_dir` scan per first write per day).
29//!
30//! Privacy controls:
31//! - `TRUSTY_MEMORY_PROMPT_LOG=off` (or `0`, `false`, `no`, case-insensitive)
32//! disables the pipeline entirely — no files created, no I/O.
33//! - `TRUSTY_MEMORY_PROMPT_LOG_HASH_PROMPTS=1` (or `true`, `yes`, `on`)
34//! replaces the raw `trigger_prompt` with `sha256:<hex>` so the file holds
35//! no plaintext user input.
36//!
37//! Failure isolation: every public method swallows I/O / serialisation errors
38//! and emits a `tracing::warn!` to stderr. The hook caller must never observe
39//! a failure path from this module.
40//!
41//! Test: see [`tests`] for round-trip, rotation, retention, disabled, hash and
42//! integration-style assertions.
43
44use std::fs::OpenOptions;
45use std::io::Write;
46use std::path::{Path, PathBuf};
47
48use chrono::{DateTime, Datelike, NaiveDate, Utc};
49use serde::{Deserialize, Serialize};
50use sha2::{Digest, Sha256};
51
52/// Env var: master switch (`off`/`0`/`false`/`no` → disabled).
53pub const ENV_ENABLED: &str = "TRUSTY_MEMORY_PROMPT_LOG";
54/// Env var: directory override (defaults to `<data_root>/logs`).
55pub const ENV_DIR: &str = "TRUSTY_MEMORY_PROMPT_LOG_DIR";
56/// Env var: per-file size cap in bytes (default `DEFAULT_MAX_BYTES`).
57pub const ENV_MAX_BYTES: &str = "TRUSTY_MEMORY_PROMPT_LOG_MAX_BYTES";
58/// Env var: retention window in days (default `DEFAULT_RETENTION_DAYS`).
59pub const ENV_RETENTION_DAYS: &str = "TRUSTY_MEMORY_PROMPT_LOG_RETENTION_DAYS";
60/// Env var: SHA-256-hash `trigger_prompt` when truthy.
61pub const ENV_HASH_PROMPTS: &str = "TRUSTY_MEMORY_PROMPT_LOG_HASH_PROMPTS";
62
63/// Default per-file size cap (50 MiB).
64pub const DEFAULT_MAX_BYTES: u64 = 50 * 1024 * 1024;
65/// Default retention window in days.
66pub const DEFAULT_RETENTION_DAYS: u32 = 30;
67/// Filename stem prefix for log files.
68const FILE_PREFIX: &str = "enriched-prompts";
69/// Filename extension for log files.
70const FILE_EXT: &str = "jsonl";
71
72/// Configuration for [`PromptLogger`].
73///
74/// Why: keeps env-parsing out of the hot path and allows tests to construct
75/// loggers directly without mutating process-wide env state. The struct is
76/// `Clone` so a logger can be cheaply re-derived per invocation.
77/// What: holds the resolved log directory, size cap, retention window, and
78/// privacy toggles. `enabled = false` short-circuits every write.
79/// Test: covered by `config_from_env_disabled` and the integration tests.
80#[derive(Clone, Debug)]
81pub struct PromptLogConfig {
82 /// Master enable switch. `false` → every method is a no-op.
83 pub enabled: bool,
84 /// Directory holding the rolling log files (created lazily on first write).
85 pub dir: PathBuf,
86 /// Per-file size cap; the writer rolls to a new numeric suffix when the
87 /// active file would exceed this size.
88 pub max_bytes: u64,
89 /// Retention window in days. Files older than this are pruned on the
90 /// first write of each day.
91 pub retention_days: u32,
92 /// Replace `trigger_prompt` field bodies with `sha256:<hex>` when true.
93 pub hash_prompts: bool,
94}
95
96impl PromptLogConfig {
97 /// Build a config rooted at the supplied `data_root` and overlayed with
98 /// env vars.
99 ///
100 /// Why: `prompt-context` and `inbox-check` both resolve their data root
101 /// via [`trusty_common::resolve_data_dir`] but only that caller knows the
102 /// app name. Accepting an explicit root lets the logger reuse the same
103 /// resolution without parsing dirs::data_dir twice.
104 /// What: defaults `dir = data_root/logs`; overrides via `TRUSTY_MEMORY_*`
105 /// envs. `enabled` defaults to `true`; flips to `false` when
106 /// `TRUSTY_MEMORY_PROMPT_LOG` is set to an off-value.
107 /// Test: `config_from_env_defaults`, `config_from_env_disabled`,
108 /// `config_from_env_overrides_dir`.
109 pub fn from_env_with_root(data_root: &Path) -> Self {
110 let enabled = match std::env::var(ENV_ENABLED) {
111 Ok(v) => !is_off(&v),
112 Err(_) => true,
113 };
114 let dir = match std::env::var(ENV_DIR) {
115 Ok(d) if !d.trim().is_empty() => PathBuf::from(d),
116 _ => data_root.join("logs"),
117 };
118 let max_bytes = std::env::var(ENV_MAX_BYTES)
119 .ok()
120 .and_then(|s| s.trim().parse::<u64>().ok())
121 .filter(|n| *n > 0)
122 .unwrap_or(DEFAULT_MAX_BYTES);
123 let retention_days = std::env::var(ENV_RETENTION_DAYS)
124 .ok()
125 .and_then(|s| s.trim().parse::<u32>().ok())
126 .filter(|n| *n > 0)
127 .unwrap_or(DEFAULT_RETENTION_DAYS);
128 let hash_prompts = std::env::var(ENV_HASH_PROMPTS)
129 .map(|v| is_on(&v))
130 .unwrap_or(false);
131 Self {
132 enabled,
133 dir,
134 max_bytes,
135 retention_days,
136 hash_prompts,
137 }
138 }
139}
140
141/// One enriched-prompt log entry — written as a single JSONL line.
142///
143/// Why: the consumer is a human running `jq` over a day's worth of injections
144/// to grade signal-vs-noise. Stable field names, RFC-3339 timestamps, and
145/// numeric byte/duration counts keep the analysis script trivial.
146/// What: tagged by `injection_kind`. `palace_facts_count` is filled for
147/// `prompt-context-facts`; `unread_messages_count` for `inbox-check-messages`.
148/// Both default to `None` so the JSON shape stays compact for entries that
149/// only have one of the two.
150/// Test: `single_event_roundtrip` writes one entry and parses it back.
151#[derive(Clone, Debug, Serialize, Deserialize)]
152pub struct PromptLogEntry {
153 /// RFC-3339 UTC timestamp set at the moment the entry is built.
154 pub timestamp: DateTime<Utc>,
155 /// `"UserPromptSubmit"` or `"SessionStart"`.
156 pub hook_type: String,
157 /// `"prompt-context-facts"` or `"inbox-check-messages"`.
158 pub injection_kind: String,
159 /// Palace id the injection was scoped to.
160 pub palace: String,
161 /// Hook stdin verbatim; replaced with `"sha256:<hex>"` when
162 /// `hash_prompts = true` in the active config.
163 pub trigger_prompt: String,
164 /// Hook stdout (the actual injection sent to Claude Code) verbatim.
165 pub injection: String,
166 /// Byte length of `injection`.
167 pub injection_length: usize,
168 /// Number of facts in the prompt-context injection, when applicable.
169 #[serde(skip_serializing_if = "Option::is_none")]
170 pub palace_facts_count: Option<usize>,
171 /// Number of unread messages in the inbox-check injection, when applicable.
172 #[serde(skip_serializing_if = "Option::is_none")]
173 pub unread_messages_count: Option<usize>,
174 /// Wall-clock duration of the invocation, in milliseconds.
175 pub duration_ms: u64,
176}
177
178impl PromptLogEntry {
179 /// Construct a new entry stamped with the current UTC time.
180 ///
181 /// Why: the hook caller has the raw fields handy but should not carry
182 /// chrono in its imports. This helper builds an entry with `timestamp`
183 /// auto-populated and zero-initialised optional counts.
184 /// What: sets `timestamp = Utc::now()` and copies the supplied fields.
185 /// Test: `single_event_roundtrip`.
186 pub fn new(
187 hook_type: impl Into<String>,
188 injection_kind: impl Into<String>,
189 palace: impl Into<String>,
190 trigger_prompt: impl Into<String>,
191 injection: impl Into<String>,
192 ) -> Self {
193 let injection = injection.into();
194 let injection_length = injection.len();
195 Self {
196 timestamp: Utc::now(),
197 hook_type: hook_type.into(),
198 injection_kind: injection_kind.into(),
199 palace: palace.into(),
200 trigger_prompt: trigger_prompt.into(),
201 injection,
202 injection_length,
203 palace_facts_count: None,
204 unread_messages_count: None,
205 duration_ms: 0,
206 }
207 }
208
209 /// Builder: set the duration this hook invocation took.
210 #[must_use]
211 pub fn with_duration_ms(mut self, ms: u64) -> Self {
212 self.duration_ms = ms;
213 self
214 }
215
216 /// Builder: attach the palace-facts count (prompt-context only).
217 #[must_use]
218 pub fn with_palace_facts_count(mut self, n: usize) -> Self {
219 self.palace_facts_count = Some(n);
220 self
221 }
222
223 /// Builder: attach the unread-messages count (inbox-check only).
224 #[must_use]
225 pub fn with_unread_messages_count(mut self, n: usize) -> Self {
226 self.unread_messages_count = Some(n);
227 self
228 }
229}
230
231/// Best-effort rolling JSONL writer.
232///
233/// Why: hook commands are short-lived (one entry per invocation), so the
234/// logger is constructed at the start of the invocation, writes one line,
235/// and drops at the end. There is no daemon path involved; cross-process
236/// concurrency is handled by `OpenOptions::append(true)` which O_APPEND
237/// atomically positions each write at end-of-file on POSIX. On Windows
238/// (not a target for this crate) the same flag delivers similar guarantees
239/// for writes under the 4 KiB pipe-atomicity threshold, which our JSONL
240/// lines comfortably fit under.
241/// What: holds an immutable `PromptLogConfig`. `log` resolves the active
242/// filename (date + numeric suffix that fits under `max_bytes`), opens the
243/// file in append mode, writes one line, then closes it. Every failure
244/// path is a `tracing::warn!` to stderr; the caller never observes an
245/// error.
246/// Test: `single_event_roundtrip`, `rotation_at_size_cap`,
247/// `retention_prunes_old_files`, `disabled_mode_writes_nothing`,
248/// `hash_mode_hashes_trigger_prompt`.
249#[derive(Clone, Debug)]
250pub struct PromptLogger {
251 config: PromptLogConfig,
252}
253
254impl PromptLogger {
255 /// Build a logger from the configured `data_root` and process env vars.
256 ///
257 /// Why: keeps the call site in `prompt_context.rs` / `inbox_check.rs` to
258 /// a single line and centralises the env-parsing rules.
259 /// What: resolves `<data_root>` via [`trusty_common::resolve_data_dir`]
260 /// using the canonical `trusty-memory` app name, then layers env overrides
261 /// via [`PromptLogConfig::from_env_with_root`]. Returns a disabled logger
262 /// when the data dir cannot be resolved — the caller proceeds normally.
263 /// Test: covered indirectly by the integration tests.
264 pub fn from_env() -> Self {
265 let data_root = trusty_common::resolve_data_dir("trusty-memory")
266 .unwrap_or_else(|_| std::env::temp_dir().join("trusty-memory"));
267 Self::from_config(PromptLogConfig::from_env_with_root(&data_root))
268 }
269
270 /// Build a logger from an explicit config (test injection point).
271 ///
272 /// Why: integration / unit tests want to pin a tempdir without polluting
273 /// process env. Same shape as `from_env`, different injection.
274 /// Test: every unit test in this module.
275 pub fn from_config(config: PromptLogConfig) -> Self {
276 Self { config }
277 }
278
279 /// Active configuration (for tests / diagnostics).
280 pub fn config(&self) -> &PromptLogConfig {
281 &self.config
282 }
283
284 /// Append one entry to the active log file.
285 ///
286 /// Why: the public API surface — exactly one call per hook invocation.
287 /// Best-effort by contract.
288 /// What: short-circuits when `enabled = false`; otherwise computes the
289 /// active filename (creating the directory and pruning stale files as
290 /// needed), serialises the entry to a single JSON line, and appends it.
291 /// Any failure (mkdir, open, write, serde) is downgraded to a
292 /// `tracing::warn!` and discarded.
293 /// Test: see module-level `tests`.
294 pub fn log(&self, entry: PromptLogEntry) {
295 if !self.config.enabled {
296 return;
297 }
298
299 // Apply hash transform before serialising so it lands on disk.
300 let entry = self.apply_privacy(entry);
301
302 // Ensure the log directory exists.
303 if let Err(e) = std::fs::create_dir_all(&self.config.dir) {
304 tracing::warn!(
305 "trusty-memory prompt log: could not create {}: {e}",
306 self.config.dir.display()
307 );
308 return;
309 }
310
311 // Opportunistic retention prune — cheap (one read_dir) and only fires
312 // when the day's first write reaches this point.
313 self.prune_if_needed();
314
315 // Resolve filename and append.
316 let path = match self.resolve_active_path(entry.timestamp) {
317 Ok(p) => p,
318 Err(e) => {
319 tracing::warn!("trusty-memory prompt log: resolve path: {e}");
320 return;
321 }
322 };
323
324 let line = match serde_json::to_string(&entry) {
325 Ok(s) => s,
326 Err(e) => {
327 tracing::warn!("trusty-memory prompt log: serialise entry: {e}");
328 return;
329 }
330 };
331
332 match OpenOptions::new().create(true).append(true).open(&path) {
333 Ok(mut f) => {
334 if let Err(e) = writeln!(f, "{line}") {
335 tracing::warn!("trusty-memory prompt log: write {}: {e}", path.display());
336 }
337 }
338 Err(e) => {
339 tracing::warn!("trusty-memory prompt log: open {}: {e}", path.display());
340 }
341 }
342 }
343
344 /// Apply privacy transformations to the entry.
345 fn apply_privacy(&self, mut entry: PromptLogEntry) -> PromptLogEntry {
346 if self.config.hash_prompts {
347 entry.trigger_prompt = hash_prompt(&entry.trigger_prompt);
348 }
349 entry
350 }
351
352 /// Resolve the path of the active log file for `timestamp`.
353 ///
354 /// Why: encapsulates the date prefix + numeric-suffix logic so the write
355 /// path stays linear. Returns the first numeric suffix whose file is
356 /// either missing or under the size cap.
357 /// What: enumerates `enriched-prompts.<date>.jsonl`,
358 /// `enriched-prompts.<date>.1.jsonl`, … and picks the smallest index
359 /// whose file size is below `max_bytes`. Stops at a hard ceiling
360 /// (`u32::MAX`) to prevent unbounded scanning if the cap is set to 0
361 /// by mistake (defended further by `from_env_with_root`'s `filter`).
362 /// Test: `rotation_at_size_cap`.
363 fn resolve_active_path(&self, timestamp: DateTime<Utc>) -> std::io::Result<PathBuf> {
364 let date_str = format!(
365 "{:04}-{:02}-{:02}",
366 timestamp.year(),
367 timestamp.month(),
368 timestamp.day()
369 );
370 let base = self
371 .config
372 .dir
373 .join(format!("{FILE_PREFIX}.{date_str}.{FILE_EXT}"));
374 // Index 0 is the bare `<date>.jsonl` file (no numeric suffix).
375 let path_for = |i: u32| -> PathBuf {
376 if i == 0 {
377 base.clone()
378 } else {
379 self.config
380 .dir
381 .join(format!("{FILE_PREFIX}.{date_str}.{i}.{FILE_EXT}"))
382 }
383 };
384 for i in 0u32..=u32::MAX {
385 let candidate = path_for(i);
386 let size = match std::fs::metadata(&candidate) {
387 Ok(m) => m.len(),
388 Err(e) if e.kind() == std::io::ErrorKind::NotFound => 0,
389 Err(e) => return Err(e),
390 };
391 if size < self.config.max_bytes {
392 return Ok(candidate);
393 }
394 }
395 // Astronomically unlikely (would require writing 50 MiB × 2^32 in a
396 // single day). Fall back to suffix u32::MAX so the write still lands.
397 Ok(path_for(u32::MAX))
398 }
399
400 /// Prune log files older than `retention_days`.
401 ///
402 /// Why: keeps unbounded disk growth in check without a daemon worker. The
403 /// check is cheap (one `read_dir`) so running it on every write is fine;
404 /// we still gate by file presence to avoid spinning before the first
405 /// write succeeds.
406 /// What: parses the `<date>` component out of each
407 /// `enriched-prompts.YYYY-MM-DD[.n].jsonl` filename and removes files
408 /// older than `today - retention_days`. Unparseable filenames are left
409 /// alone. Errors are logged at `warn!` and ignored.
410 /// Test: `retention_prunes_old_files`.
411 fn prune_if_needed(&self) {
412 let today = Utc::now().date_naive();
413 let cutoff =
414 match today.checked_sub_days(chrono::Days::new(self.config.retention_days as u64)) {
415 Some(d) => d,
416 None => return,
417 };
418 let dir = match std::fs::read_dir(&self.config.dir) {
419 Ok(d) => d,
420 Err(_) => return,
421 };
422 for entry in dir.flatten() {
423 let name = entry.file_name();
424 let name = match name.to_str() {
425 Some(s) => s,
426 None => continue,
427 };
428 let date = match parse_log_filename_date(name) {
429 Some(d) => d,
430 None => continue,
431 };
432 if date < cutoff {
433 if let Err(e) = std::fs::remove_file(entry.path()) {
434 tracing::warn!(
435 "trusty-memory prompt log: prune {}: {e}",
436 entry.path().display()
437 );
438 }
439 }
440 }
441 }
442}
443
444/// Parse the date out of `enriched-prompts.YYYY-MM-DD[.n].jsonl`.
445///
446/// Why: retention pruning needs to identify the date stamp without parsing
447/// every JSONL line. Returning `None` for unrelated files keeps the prune
448/// idempotent — we never touch files we don't recognise.
449/// What: strips the `enriched-prompts.` prefix and `.jsonl` (or `.N.jsonl`)
450/// suffix; parses what's left as `NaiveDate`. Returns `None` on any
451/// shape mismatch.
452/// Test: `parse_filename_date_parses_canonical_and_rotated`.
453fn parse_log_filename_date(name: &str) -> Option<NaiveDate> {
454 let prefix = format!("{FILE_PREFIX}.");
455 let suffix = format!(".{FILE_EXT}");
456 let inner = name.strip_prefix(&prefix)?.strip_suffix(&suffix)?;
457 // `inner` is either `YYYY-MM-DD` or `YYYY-MM-DD.N`.
458 let date_part = match inner.find('.') {
459 Some(i) => &inner[..i],
460 None => inner,
461 };
462 NaiveDate::parse_from_str(date_part, "%Y-%m-%d").ok()
463}
464
465/// SHA-256 the supplied prompt and prefix with `sha256:`.
466///
467/// Why: the privacy-preserving alternative to logging raw user input.
468/// What: returns `sha256:<lowercase hex>` so consumers can spot the
469/// transformed field at a glance.
470/// Test: `hash_mode_hashes_trigger_prompt`.
471fn hash_prompt(text: &str) -> String {
472 let mut hasher = Sha256::new();
473 hasher.update(text.as_bytes());
474 let digest = hasher.finalize();
475 format!("sha256:{digest:x}")
476}
477
478/// True when the value looks like an explicit off switch.
479fn is_off(v: &str) -> bool {
480 matches!(
481 v.trim().to_ascii_lowercase().as_str(),
482 "0" | "off" | "false" | "no" | "disabled"
483 )
484}
485
486/// True when the value looks like an explicit on switch.
487fn is_on(v: &str) -> bool {
488 matches!(
489 v.trim().to_ascii_lowercase().as_str(),
490 "1" | "on" | "true" | "yes" | "enabled"
491 )
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497 use std::path::PathBuf;
498
499 /// Helper: build a logger pointed at a tempdir's `logs/` subdir.
500 fn logger_in(
501 dir: &Path,
502 hash_prompts: bool,
503 max_bytes: u64,
504 retention_days: u32,
505 ) -> PromptLogger {
506 PromptLogger::from_config(PromptLogConfig {
507 enabled: true,
508 dir: dir.join("logs"),
509 max_bytes,
510 retention_days,
511 hash_prompts,
512 })
513 }
514
515 fn read_jsonl_lines(path: &Path) -> Vec<String> {
516 std::fs::read_to_string(path)
517 .unwrap_or_default()
518 .lines()
519 .map(|l| l.to_string())
520 .collect()
521 }
522
523 fn list_log_files(dir: &Path) -> Vec<PathBuf> {
524 let logs_dir = dir.join("logs");
525 let mut out: Vec<PathBuf> = std::fs::read_dir(&logs_dir)
526 .map(|rd| {
527 rd.flatten()
528 .map(|e| e.path())
529 .filter(|p| {
530 p.file_name()
531 .and_then(|n| n.to_str())
532 .is_some_and(|n| n.starts_with(FILE_PREFIX))
533 })
534 .collect()
535 })
536 .unwrap_or_default();
537 out.sort();
538 out
539 }
540
541 /// Why: every other test in the module depends on the basic round-trip
542 /// shape. This pins it.
543 /// What: write one entry through `log`, find the resulting file, parse
544 /// the single line, and assert all fields survive intact.
545 /// Test: itself.
546 #[test]
547 fn single_event_roundtrip() {
548 let tmp = tempfile::tempdir().expect("tempdir");
549 let logger = logger_in(tmp.path(), false, DEFAULT_MAX_BYTES, 30);
550
551 let entry = PromptLogEntry::new(
552 "UserPromptSubmit",
553 "prompt-context-facts",
554 "test-palace",
555 "what tools should I use?",
556 "## Context\n- alias: tm -> trusty-memory\n",
557 )
558 .with_duration_ms(12)
559 .with_palace_facts_count(7);
560
561 logger.log(entry.clone());
562
563 let files = list_log_files(tmp.path());
564 assert_eq!(
565 files.len(),
566 1,
567 "expected exactly one log file, got {files:?}"
568 );
569 let lines = read_jsonl_lines(&files[0]);
570 assert_eq!(lines.len(), 1, "expected one JSONL line, got {lines:?}");
571 let parsed: PromptLogEntry = serde_json::from_str(&lines[0]).expect("parse JSONL entry");
572
573 assert_eq!(parsed.hook_type, "UserPromptSubmit");
574 assert_eq!(parsed.injection_kind, "prompt-context-facts");
575 assert_eq!(parsed.palace, "test-palace");
576 assert_eq!(parsed.trigger_prompt, "what tools should I use?");
577 assert_eq!(parsed.injection, entry.injection);
578 assert_eq!(parsed.injection_length, entry.injection.len());
579 assert_eq!(parsed.palace_facts_count, Some(7));
580 assert_eq!(parsed.unread_messages_count, None);
581 assert_eq!(parsed.duration_ms, 12);
582 }
583
584 /// Why: size-based rotation is the harder of the two rotation rules to
585 /// get right; date rotation only fires once a day. We pin a tiny cap and
586 /// write enough entries to force at least one roll.
587 /// What: max_bytes = 200; write 5 entries with ~120-byte injections; assert
588 /// at least two log files exist after the run.
589 /// Test: itself.
590 #[test]
591 fn rotation_at_size_cap() {
592 let tmp = tempfile::tempdir().expect("tempdir");
593 let logger = logger_in(tmp.path(), false, 200, 30);
594
595 for i in 0..5 {
596 let entry = PromptLogEntry::new(
597 "UserPromptSubmit",
598 "prompt-context-facts",
599 "test-palace",
600 format!("prompt #{i} with some padding to push us over the cap"),
601 format!("injection #{i} with some padding to push us over the cap"),
602 )
603 .with_duration_ms(i as u64);
604 logger.log(entry);
605 }
606
607 let files = list_log_files(tmp.path());
608 assert!(
609 files.len() >= 2,
610 "expected rotation to produce at least two files, got {files:?}"
611 );
612 }
613
614 /// Why: stale files must be pruned so disk usage stays bounded. Forge a
615 /// file with a date older than the window and assert it disappears on
616 /// the next write.
617 /// What: retention=2 days; pre-create `enriched-prompts.<old>.jsonl`
618 /// dated 90 days ago; write a fresh entry; assert the stale file is
619 /// gone and the new file exists.
620 /// Test: itself.
621 #[test]
622 fn retention_prunes_old_files() {
623 let tmp = tempfile::tempdir().expect("tempdir");
624 let logs_dir = tmp.path().join("logs");
625 std::fs::create_dir_all(&logs_dir).unwrap();
626
627 // Forge a stale log file dated 90 days ago.
628 let stale_date = Utc::now()
629 .date_naive()
630 .checked_sub_days(chrono::Days::new(90))
631 .expect("stale date");
632 let stale_name = format!(
633 "{FILE_PREFIX}.{:04}-{:02}-{:02}.{FILE_EXT}",
634 stale_date.year(),
635 stale_date.month(),
636 stale_date.day()
637 );
638 let stale_path = logs_dir.join(&stale_name);
639 std::fs::write(&stale_path, "{\"stale\": true}\n").unwrap();
640
641 // Also forge an unrelated file that must NOT be pruned.
642 let unrelated = logs_dir.join("not-our-log.txt");
643 std::fs::write(&unrelated, "ignore me").unwrap();
644
645 let logger = logger_in(tmp.path(), false, DEFAULT_MAX_BYTES, 2);
646 logger.log(PromptLogEntry::new(
647 "UserPromptSubmit",
648 "prompt-context-facts",
649 "test-palace",
650 "trigger",
651 "injection",
652 ));
653
654 assert!(
655 !stale_path.exists(),
656 "stale log file at {} should have been pruned",
657 stale_path.display()
658 );
659 assert!(
660 unrelated.exists(),
661 "unrelated file at {} must not be touched",
662 unrelated.display()
663 );
664 let files = list_log_files(tmp.path());
665 // A fresh entry must have produced *some* current-day file.
666 let today = Utc::now().date_naive();
667 let expected_today = format!(
668 "{FILE_PREFIX}.{:04}-{:02}-{:02}.{FILE_EXT}",
669 today.year(),
670 today.month(),
671 today.day()
672 );
673 assert!(
674 files.iter().any(|p| p
675 .file_name()
676 .and_then(|n| n.to_str())
677 .is_some_and(|n| n == expected_today)),
678 "expected today's log file `{expected_today}` to exist, got {files:?}"
679 );
680 }
681
682 /// Why: the opt-out switch is the most important privacy guarantee.
683 /// What: build a disabled logger, write one entry, assert no files exist
684 /// under the configured directory.
685 /// Test: itself.
686 #[test]
687 fn disabled_mode_writes_nothing() {
688 let tmp = tempfile::tempdir().expect("tempdir");
689 let logger = PromptLogger::from_config(PromptLogConfig {
690 enabled: false,
691 dir: tmp.path().join("logs"),
692 max_bytes: DEFAULT_MAX_BYTES,
693 retention_days: 30,
694 hash_prompts: false,
695 });
696 logger.log(PromptLogEntry::new(
697 "UserPromptSubmit",
698 "prompt-context-facts",
699 "test-palace",
700 "trigger",
701 "injection",
702 ));
703
704 // The logs directory should not be created.
705 assert!(
706 !tmp.path().join("logs").exists(),
707 "disabled logger must not create the log directory"
708 );
709 }
710
711 /// Why: the hash-prompts mode is the second privacy guarantee — raw user
712 /// input must never land on disk.
713 /// What: enable `hash_prompts`, write an entry with a known prompt,
714 /// parse the resulting JSON, assert `trigger_prompt` starts with
715 /// `sha256:` and matches a known digest. Also assert the raw prompt
716 /// text never appears in the file.
717 /// Test: itself.
718 #[test]
719 fn hash_mode_hashes_trigger_prompt() {
720 let tmp = tempfile::tempdir().expect("tempdir");
721 let logger = logger_in(tmp.path(), true, DEFAULT_MAX_BYTES, 30);
722
723 let raw_prompt = "secret user prompt that must not land on disk";
724 logger.log(PromptLogEntry::new(
725 "UserPromptSubmit",
726 "prompt-context-facts",
727 "test-palace",
728 raw_prompt,
729 "injection body",
730 ));
731
732 let files = list_log_files(tmp.path());
733 assert_eq!(files.len(), 1);
734 let content = std::fs::read_to_string(&files[0]).unwrap();
735 assert!(
736 !content.contains(raw_prompt),
737 "raw prompt must not appear in the log file; got {content}"
738 );
739 let parsed: PromptLogEntry = serde_json::from_str(content.trim()).expect("parse JSONL");
740 assert!(
741 parsed.trigger_prompt.starts_with("sha256:"),
742 "trigger_prompt should be hashed, got {}",
743 parsed.trigger_prompt
744 );
745 // Cross-check the digest.
746 assert_eq!(parsed.trigger_prompt, hash_prompt(raw_prompt));
747 }
748
749 /// Why: the env-driven config path is the production code path. Test it
750 /// directly so the rules cannot drift silently.
751 /// What: with no env set, defaults are picked up; with the off switch,
752 /// `enabled = false`; with explicit overrides, custom values appear.
753 /// Test: itself.
754 #[tokio::test]
755 async fn config_from_env_defaults() {
756 // Serialise with the commands::env_test_lock so this test cannot race
757 // the env-touching integration tests in `commands::prompt_context`
758 // / `commands::inbox_check`.
759 let _guard = crate::commands::env_test_lock().lock().await;
760 let tmp = tempfile::tempdir().expect("tempdir");
761 // Snapshot and clear so the test doesn't observe contamination from
762 // other tests in the same process.
763 let prev_enabled = std::env::var(ENV_ENABLED).ok();
764 let prev_dir = std::env::var(ENV_DIR).ok();
765 let prev_max = std::env::var(ENV_MAX_BYTES).ok();
766 let prev_ret = std::env::var(ENV_RETENTION_DAYS).ok();
767 let prev_hash = std::env::var(ENV_HASH_PROMPTS).ok();
768 // SAFETY: env mutation. Restored at end of test.
769 unsafe {
770 std::env::remove_var(ENV_ENABLED);
771 std::env::remove_var(ENV_DIR);
772 std::env::remove_var(ENV_MAX_BYTES);
773 std::env::remove_var(ENV_RETENTION_DAYS);
774 std::env::remove_var(ENV_HASH_PROMPTS);
775 }
776 let cfg = PromptLogConfig::from_env_with_root(tmp.path());
777 assert!(cfg.enabled);
778 assert_eq!(cfg.dir, tmp.path().join("logs"));
779 assert_eq!(cfg.max_bytes, DEFAULT_MAX_BYTES);
780 assert_eq!(cfg.retention_days, DEFAULT_RETENTION_DAYS);
781 assert!(!cfg.hash_prompts);
782 // Restore.
783 unsafe {
784 for (k, v) in [
785 (ENV_ENABLED, prev_enabled),
786 (ENV_DIR, prev_dir),
787 (ENV_MAX_BYTES, prev_max),
788 (ENV_RETENTION_DAYS, prev_ret),
789 (ENV_HASH_PROMPTS, prev_hash),
790 ] {
791 if let Some(val) = v {
792 std::env::set_var(k, val);
793 } else {
794 std::env::remove_var(k);
795 }
796 }
797 }
798 }
799
800 /// Why: every value of the off-switch must produce a disabled logger.
801 #[test]
802 fn is_off_matches_documented_values() {
803 for v in ["0", "off", "OFF", "Off", "false", "False", "no", "disabled"] {
804 assert!(is_off(v), "{v} should be parsed as off");
805 }
806 for v in ["1", "on", "true", "yes", "yeah", ""] {
807 assert!(!is_off(v), "{v} should NOT be parsed as off");
808 }
809 }
810
811 /// Why: hash-mode toggle has its own truthiness set.
812 #[test]
813 fn is_on_matches_documented_values() {
814 for v in ["1", "on", "ON", "true", "True", "yes", "enabled"] {
815 assert!(is_on(v), "{v} should be parsed as on");
816 }
817 for v in ["0", "off", "false", "no", ""] {
818 assert!(!is_on(v), "{v} should NOT be parsed as on");
819 }
820 }
821
822 /// Why: the filename parser is the linchpin of retention. Pin its
823 /// recognised shapes so retention can't accidentally start deleting
824 /// random files.
825 #[test]
826 fn parse_filename_date_parses_canonical_and_rotated() {
827 let canonical = "enriched-prompts.2026-05-25.jsonl";
828 let rotated = "enriched-prompts.2026-05-25.3.jsonl";
829 let canonical_date = parse_log_filename_date(canonical).expect("canonical parses");
830 let rotated_date = parse_log_filename_date(rotated).expect("rotated parses");
831 assert_eq!(canonical_date, rotated_date);
832 assert_eq!(
833 canonical_date,
834 NaiveDate::from_ymd_opt(2026, 5, 25).unwrap()
835 );
836
837 for bad in [
838 "not-our-log.txt",
839 "enriched-prompts..jsonl",
840 "enriched-prompts.bogus.jsonl",
841 "enriched-prompts.2026-13-99.jsonl",
842 "enriched-prompts.2026-05-25.txt",
843 "other-prefix.2026-05-25.jsonl",
844 ] {
845 assert!(
846 parse_log_filename_date(bad).is_none(),
847 "should not parse: {bad}"
848 );
849 }
850 }
851}