Skip to main content

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}