Skip to main content

trusty_memory/prompt_log/
writer.rs

1//! Rolling JSONL writer for the enriched-prompt logger.
2//!
3//! Why: Isolates the file-rotation, retention-prune, and hash-privacy logic
4//! from the type definitions so each file stays under the 500-SLOC cap.
5//! What: `PromptLogger` — a best-effort rolling writer that appends one
6//! `PromptLogEntry` per `log()` call. `parse_log_filename_date` and
7//! `hash_prompt` are private helpers used only here.
8//! Test: `single_event_roundtrip`, `rotation_at_size_cap`,
9//! `retention_prunes_old_files`, `disabled_mode_writes_nothing`,
10//! `hash_mode_hashes_trigger_prompt`.
11
12use std::fs::OpenOptions;
13use std::io::Write;
14use std::path::PathBuf;
15
16use chrono::{DateTime, Datelike, NaiveDate, Utc};
17use sha2::{Digest, Sha256};
18
19use super::config::{PromptLogConfig, PromptLogEntry};
20
21/// Filename stem prefix for log files.
22const FILE_PREFIX: &str = "enriched-prompts";
23/// Filename extension for log files.
24const FILE_EXT: &str = "jsonl";
25
26/// Best-effort rolling JSONL writer.
27///
28/// Why: hook commands are short-lived (one entry per invocation), so the
29/// logger is constructed at the start of the invocation, writes one line,
30/// and drops at the end. There is no daemon path involved; cross-process
31/// concurrency is handled by `OpenOptions::append(true)` which O_APPEND
32/// atomically positions each write at end-of-file on POSIX. On Windows
33/// (not a target for this crate) the same flag delivers similar guarantees
34/// for writes under the 4 KiB pipe-atomicity threshold, which our JSONL
35/// lines comfortably fit under.
36/// What: holds an immutable `PromptLogConfig`. `log` resolves the active
37/// filename (date + numeric suffix that fits under `max_bytes`), opens the
38/// file in append mode, writes one line, then closes it. Every failure
39/// path is a `tracing::warn!` to stderr; the caller never observes an
40/// error.
41/// Test: `single_event_roundtrip`, `rotation_at_size_cap`,
42/// `retention_prunes_old_files`, `disabled_mode_writes_nothing`,
43/// `hash_mode_hashes_trigger_prompt`.
44#[derive(Clone, Debug)]
45pub struct PromptLogger {
46    config: PromptLogConfig,
47}
48
49impl PromptLogger {
50    /// Build a logger from the configured `data_root` and process env vars.
51    ///
52    /// Why: keeps the call site in `prompt_context.rs` / `inbox_check.rs` to
53    /// a single line and centralises the env-parsing rules.
54    /// What: resolves `<data_root>` via [`trusty_common::resolve_data_dir`]
55    /// using the canonical `trusty-memory` app name, then layers env overrides
56    /// via [`PromptLogConfig::from_env_with_root`]. Returns a disabled logger
57    /// when the data dir cannot be resolved — the caller proceeds normally.
58    /// Test: covered indirectly by the integration tests.
59    pub fn from_env() -> Self {
60        let data_root = trusty_common::resolve_data_dir("trusty-memory")
61            .unwrap_or_else(|_| std::env::temp_dir().join("trusty-memory"));
62        Self::from_config(PromptLogConfig::from_env_with_root(&data_root))
63    }
64
65    /// Build a logger from an explicit config (test injection point).
66    ///
67    /// Why: integration / unit tests want to pin a tempdir without polluting
68    /// process env. Same shape as `from_env`, different injection.
69    /// Test: every unit test in this module.
70    pub fn from_config(config: PromptLogConfig) -> Self {
71        Self { config }
72    }
73
74    /// Active configuration (for tests / diagnostics).
75    pub fn config(&self) -> &PromptLogConfig {
76        &self.config
77    }
78
79    /// Append one entry to the active log file.
80    ///
81    /// Why: the public API surface — exactly one call per hook invocation.
82    /// Best-effort by contract.
83    /// What: short-circuits when `enabled = false`; otherwise computes the
84    /// active filename (creating the directory and pruning stale files as
85    /// needed), serialises the entry to a single JSON line, and appends it.
86    /// Any failure (mkdir, open, write, serde) is downgraded to a
87    /// `tracing::warn!` and discarded.
88    /// Test: see module-level `tests`.
89    pub fn log(&self, entry: PromptLogEntry) {
90        if !self.config.enabled {
91            return;
92        }
93
94        // Apply hash transform before serialising so it lands on disk.
95        let entry = self.apply_privacy(entry);
96
97        // Ensure the log directory exists.
98        if let Err(e) = std::fs::create_dir_all(&self.config.dir) {
99            tracing::warn!(
100                "trusty-memory prompt log: could not create {}: {e}",
101                self.config.dir.display()
102            );
103            return;
104        }
105
106        // Opportunistic retention prune — cheap (one read_dir) and only fires
107        // when the day's first write reaches this point.
108        self.prune_if_needed();
109
110        // Resolve filename and append.
111        let path = match self.resolve_active_path(entry.timestamp) {
112            Ok(p) => p,
113            Err(e) => {
114                tracing::warn!("trusty-memory prompt log: resolve path: {e}");
115                return;
116            }
117        };
118
119        let line = match serde_json::to_string(&entry) {
120            Ok(s) => s,
121            Err(e) => {
122                tracing::warn!("trusty-memory prompt log: serialise entry: {e}");
123                return;
124            }
125        };
126
127        match OpenOptions::new().create(true).append(true).open(&path) {
128            Ok(mut f) => {
129                if let Err(e) = writeln!(f, "{line}") {
130                    tracing::warn!("trusty-memory prompt log: write {}: {e}", path.display());
131                }
132            }
133            Err(e) => {
134                tracing::warn!("trusty-memory prompt log: open {}: {e}", path.display());
135            }
136        }
137    }
138
139    /// Apply privacy transformations to the entry.
140    fn apply_privacy(&self, mut entry: PromptLogEntry) -> PromptLogEntry {
141        if self.config.hash_prompts {
142            entry.trigger_prompt = hash_prompt(&entry.trigger_prompt);
143        }
144        entry
145    }
146
147    /// Resolve the path of the active log file for `timestamp`.
148    ///
149    /// Why: encapsulates the date prefix + numeric-suffix logic so the write
150    /// path stays linear. Returns the first numeric suffix whose file is
151    /// either missing or under the size cap.
152    /// What: enumerates `enriched-prompts.<date>.jsonl`,
153    /// `enriched-prompts.<date>.1.jsonl`, … and picks the smallest index
154    /// whose file size is below `max_bytes`. Stops at a hard ceiling
155    /// (`u32::MAX`) to prevent unbounded scanning if the cap is set to 0
156    /// by mistake (defended further by `from_env_with_root`'s `filter`).
157    /// Test: `rotation_at_size_cap`.
158    fn resolve_active_path(&self, timestamp: DateTime<Utc>) -> std::io::Result<PathBuf> {
159        let date_str = format!(
160            "{:04}-{:02}-{:02}",
161            timestamp.year(),
162            timestamp.month(),
163            timestamp.day()
164        );
165        let base = self
166            .config
167            .dir
168            .join(format!("{FILE_PREFIX}.{date_str}.{FILE_EXT}"));
169        // Index 0 is the bare `<date>.jsonl` file (no numeric suffix).
170        let path_for = |i: u32| -> PathBuf {
171            if i == 0 {
172                base.clone()
173            } else {
174                self.config
175                    .dir
176                    .join(format!("{FILE_PREFIX}.{date_str}.{i}.{FILE_EXT}"))
177            }
178        };
179        for i in 0u32..=u32::MAX {
180            let candidate = path_for(i);
181            let size = match std::fs::metadata(&candidate) {
182                Ok(m) => m.len(),
183                Err(e) if e.kind() == std::io::ErrorKind::NotFound => 0,
184                Err(e) => return Err(e),
185            };
186            if size < self.config.max_bytes {
187                return Ok(candidate);
188            }
189        }
190        // Astronomically unlikely (would require writing 50 MiB × 2^32 in a
191        // single day). Fall back to suffix u32::MAX so the write still lands.
192        Ok(path_for(u32::MAX))
193    }
194
195    /// Prune log files older than `retention_days`.
196    ///
197    /// Why: keeps unbounded disk growth in check without a daemon worker. The
198    /// check is cheap (one `read_dir`) so running it on every write is fine;
199    /// we still gate by file presence to avoid spinning before the first
200    /// write succeeds.
201    /// What: parses the `<date>` component out of each
202    /// `enriched-prompts.YYYY-MM-DD[.n].jsonl` filename and removes files
203    /// older than `today - retention_days`. Unparseable filenames are left
204    /// alone. Errors are logged at `warn!` and ignored.
205    /// Test: `retention_prunes_old_files`.
206    fn prune_if_needed(&self) {
207        let today = Utc::now().date_naive();
208        let cutoff =
209            match today.checked_sub_days(chrono::Days::new(self.config.retention_days as u64)) {
210                Some(d) => d,
211                None => return,
212            };
213        let dir = match std::fs::read_dir(&self.config.dir) {
214            Ok(d) => d,
215            Err(_) => return,
216        };
217        for entry in dir.flatten() {
218            let name = entry.file_name();
219            let name = match name.to_str() {
220                Some(s) => s,
221                None => continue,
222            };
223            let date = match parse_log_filename_date(name) {
224                Some(d) => d,
225                None => continue,
226            };
227            if date < cutoff {
228                if let Err(e) = std::fs::remove_file(entry.path()) {
229                    tracing::warn!(
230                        "trusty-memory prompt log: prune {}: {e}",
231                        entry.path().display()
232                    );
233                }
234            }
235        }
236    }
237}
238
239/// Parse the date out of `enriched-prompts.YYYY-MM-DD[.n].jsonl`.
240///
241/// Why: retention pruning needs to identify the date stamp without parsing
242/// every JSONL line. Returning `None` for unrelated files keeps the prune
243/// idempotent — we never touch files we don't recognise.
244/// What: strips the `enriched-prompts.` prefix and `.jsonl` (or `.N.jsonl`)
245/// suffix; parses what's left as `NaiveDate`. Returns `None` on any
246/// shape mismatch.
247/// Test: `parse_filename_date_parses_canonical_and_rotated`.
248fn parse_log_filename_date(name: &str) -> Option<NaiveDate> {
249    let prefix = format!("{FILE_PREFIX}.");
250    let suffix = format!(".{FILE_EXT}");
251    let inner = name.strip_prefix(&prefix)?.strip_suffix(&suffix)?;
252    // `inner` is either `YYYY-MM-DD` or `YYYY-MM-DD.N`.
253    let date_part = match inner.find('.') {
254        Some(i) => &inner[..i],
255        None => inner,
256    };
257    NaiveDate::parse_from_str(date_part, "%Y-%m-%d").ok()
258}
259
260/// SHA-256 the supplied prompt and prefix with `sha256:`.
261///
262/// Why: the privacy-preserving alternative to logging raw user input.
263/// What: returns `sha256:<lowercase hex>` so consumers can spot the
264/// transformed field at a glance.
265/// Test: `hash_mode_hashes_trigger_prompt`.
266pub(super) fn hash_prompt(text: &str) -> String {
267    let mut hasher = Sha256::new();
268    hasher.update(text.as_bytes());
269    let digest = hasher.finalize();
270    format!("sha256:{digest:x}")
271}
272
273#[cfg(test)]
274mod tests {
275    use super::super::config::{
276        is_off, is_on, DEFAULT_MAX_BYTES, DEFAULT_RETENTION_DAYS, ENV_DIR, ENV_ENABLED,
277        ENV_HASH_PROMPTS, ENV_MAX_BYTES, ENV_RETENTION_DAYS,
278    };
279    use super::*;
280    use std::path::{Path, PathBuf};
281
282    /// Helper: build a logger pointed at a tempdir's `logs/` subdir.
283    fn logger_in(
284        dir: &Path,
285        hash_prompts: bool,
286        max_bytes: u64,
287        retention_days: u32,
288    ) -> PromptLogger {
289        PromptLogger::from_config(PromptLogConfig {
290            enabled: true,
291            dir: dir.join("logs"),
292            max_bytes,
293            retention_days,
294            hash_prompts,
295        })
296    }
297
298    fn read_jsonl_lines(path: &Path) -> Vec<String> {
299        std::fs::read_to_string(path)
300            .unwrap_or_default()
301            .lines()
302            .map(|l| l.to_string())
303            .collect()
304    }
305
306    fn list_log_files(dir: &Path) -> Vec<PathBuf> {
307        let logs_dir = dir.join("logs");
308        let mut out: Vec<PathBuf> = std::fs::read_dir(&logs_dir)
309            .map(|rd| {
310                rd.flatten()
311                    .map(|e| e.path())
312                    .filter(|p| {
313                        p.file_name()
314                            .and_then(|n| n.to_str())
315                            .is_some_and(|n| n.starts_with(FILE_PREFIX))
316                    })
317                    .collect()
318            })
319            .unwrap_or_default();
320        out.sort();
321        out
322    }
323
324    /// Why: every other test in the module depends on the basic round-trip
325    /// shape. This pins it.
326    /// What: write one entry through `log`, find the resulting file, parse
327    /// the single line, and assert all fields survive intact.
328    /// Test: itself.
329    #[test]
330    fn single_event_roundtrip() {
331        let tmp = tempfile::tempdir().expect("tempdir");
332        let logger = logger_in(tmp.path(), false, DEFAULT_MAX_BYTES, 30);
333
334        let entry = PromptLogEntry::new(
335            "UserPromptSubmit",
336            "prompt-context-facts",
337            "test-palace",
338            "what tools should I use?",
339            "## Context\n- alias: tm -> trusty-memory\n",
340        )
341        .with_duration_ms(12)
342        .with_palace_facts_count(7);
343
344        logger.log(entry.clone());
345
346        let files = list_log_files(tmp.path());
347        assert_eq!(
348            files.len(),
349            1,
350            "expected exactly one log file, got {files:?}"
351        );
352        let lines = read_jsonl_lines(&files[0]);
353        assert_eq!(lines.len(), 1, "expected one JSONL line, got {lines:?}");
354        let parsed: PromptLogEntry = serde_json::from_str(&lines[0]).expect("parse JSONL entry");
355
356        assert_eq!(parsed.hook_type, "UserPromptSubmit");
357        assert_eq!(parsed.injection_kind, "prompt-context-facts");
358        assert_eq!(parsed.palace, "test-palace");
359        assert_eq!(parsed.trigger_prompt, "what tools should I use?");
360        assert_eq!(parsed.injection, entry.injection);
361        assert_eq!(parsed.injection_length, entry.injection.len());
362        assert_eq!(parsed.palace_facts_count, Some(7));
363        assert_eq!(parsed.unread_messages_count, None);
364        assert_eq!(parsed.duration_ms, 12);
365    }
366
367    /// Why: size-based rotation is the harder of the two rotation rules to
368    /// get right; date rotation only fires once a day. We pin a tiny cap and
369    /// write enough entries to force at least one roll.
370    /// What: max_bytes = 200; write 5 entries with ~120-byte injections; assert
371    /// at least two log files exist after the run.
372    /// Test: itself.
373    #[test]
374    fn rotation_at_size_cap() {
375        let tmp = tempfile::tempdir().expect("tempdir");
376        let logger = logger_in(tmp.path(), false, 200, 30);
377
378        for i in 0..5 {
379            let entry = PromptLogEntry::new(
380                "UserPromptSubmit",
381                "prompt-context-facts",
382                "test-palace",
383                format!("prompt #{i} with some padding to push us over the cap"),
384                format!("injection #{i} with some padding to push us over the cap"),
385            )
386            .with_duration_ms(i as u64);
387            logger.log(entry);
388        }
389
390        let files = list_log_files(tmp.path());
391        assert!(
392            files.len() >= 2,
393            "expected rotation to produce at least two files, got {files:?}"
394        );
395    }
396
397    /// Why: stale files must be pruned so disk usage stays bounded. Forge a
398    /// file with a date older than the window and assert it disappears on
399    /// the next write.
400    /// What: retention=2 days; pre-create `enriched-prompts.<old>.jsonl`
401    /// dated 90 days ago; write a fresh entry; assert the stale file is
402    /// gone and the new file exists.
403    /// Test: itself.
404    #[test]
405    fn retention_prunes_old_files() {
406        use chrono::Datelike;
407        let tmp = tempfile::tempdir().expect("tempdir");
408        let logs_dir = tmp.path().join("logs");
409        std::fs::create_dir_all(&logs_dir).unwrap();
410
411        // Forge a stale log file dated 90 days ago.
412        let stale_date = Utc::now()
413            .date_naive()
414            .checked_sub_days(chrono::Days::new(90))
415            .expect("stale date");
416        let stale_name = format!(
417            "{FILE_PREFIX}.{:04}-{:02}-{:02}.{FILE_EXT}",
418            stale_date.year(),
419            stale_date.month(),
420            stale_date.day()
421        );
422        let stale_path = logs_dir.join(&stale_name);
423        std::fs::write(&stale_path, "{\"stale\": true}\n").unwrap();
424
425        // Also forge an unrelated file that must NOT be pruned.
426        let unrelated = logs_dir.join("not-our-log.txt");
427        std::fs::write(&unrelated, "ignore me").unwrap();
428
429        let logger = logger_in(tmp.path(), false, DEFAULT_MAX_BYTES, 2);
430        logger.log(PromptLogEntry::new(
431            "UserPromptSubmit",
432            "prompt-context-facts",
433            "test-palace",
434            "trigger",
435            "injection",
436        ));
437
438        assert!(
439            !stale_path.exists(),
440            "stale log file at {} should have been pruned",
441            stale_path.display()
442        );
443        assert!(
444            unrelated.exists(),
445            "unrelated file at {} must not be touched",
446            unrelated.display()
447        );
448        let files = list_log_files(tmp.path());
449        // A fresh entry must have produced *some* current-day file.
450        let today = Utc::now().date_naive();
451        let expected_today = format!(
452            "{FILE_PREFIX}.{:04}-{:02}-{:02}.{FILE_EXT}",
453            today.year(),
454            today.month(),
455            today.day()
456        );
457        assert!(
458            files.iter().any(|p| p
459                .file_name()
460                .and_then(|n| n.to_str())
461                .is_some_and(|n| n == expected_today)),
462            "expected today's log file `{expected_today}` to exist, got {files:?}"
463        );
464    }
465
466    /// Why: the opt-out switch is the most important privacy guarantee.
467    /// What: build a disabled logger, write one entry, assert no files exist
468    /// under the configured directory.
469    /// Test: itself.
470    #[test]
471    fn disabled_mode_writes_nothing() {
472        let tmp = tempfile::tempdir().expect("tempdir");
473        let logger = PromptLogger::from_config(PromptLogConfig {
474            enabled: false,
475            dir: tmp.path().join("logs"),
476            max_bytes: DEFAULT_MAX_BYTES,
477            retention_days: 30,
478            hash_prompts: false,
479        });
480        logger.log(PromptLogEntry::new(
481            "UserPromptSubmit",
482            "prompt-context-facts",
483            "test-palace",
484            "trigger",
485            "injection",
486        ));
487
488        // The logs directory should not be created.
489        assert!(
490            !tmp.path().join("logs").exists(),
491            "disabled logger must not create the log directory"
492        );
493    }
494
495    /// Why: the hash-prompts mode is the second privacy guarantee — raw user
496    /// input must never land on disk.
497    /// What: enable `hash_prompts`, write an entry with a known prompt,
498    /// parse the resulting JSON, assert `trigger_prompt` starts with
499    /// `sha256:` and matches a known digest. Also assert the raw prompt
500    /// text never appears in the file.
501    /// Test: itself.
502    #[test]
503    fn hash_mode_hashes_trigger_prompt() {
504        let tmp = tempfile::tempdir().expect("tempdir");
505        let logger = logger_in(tmp.path(), true, DEFAULT_MAX_BYTES, 30);
506
507        let raw_prompt = "secret user prompt that must not land on disk";
508        logger.log(PromptLogEntry::new(
509            "UserPromptSubmit",
510            "prompt-context-facts",
511            "test-palace",
512            raw_prompt,
513            "injection body",
514        ));
515
516        let files = list_log_files(tmp.path());
517        assert_eq!(files.len(), 1);
518        let content = std::fs::read_to_string(&files[0]).unwrap();
519        assert!(
520            !content.contains(raw_prompt),
521            "raw prompt must not appear in the log file; got {content}"
522        );
523        let parsed: PromptLogEntry = serde_json::from_str(content.trim()).expect("parse JSONL");
524        assert!(
525            parsed.trigger_prompt.starts_with("sha256:"),
526            "trigger_prompt should be hashed, got {}",
527            parsed.trigger_prompt
528        );
529        // Cross-check the digest.
530        assert_eq!(parsed.trigger_prompt, hash_prompt(raw_prompt));
531    }
532
533    /// Why: the env-driven config path is the production code path. Test it
534    /// directly so the rules cannot drift silently.
535    /// What: with no env set, defaults are picked up; with the off switch,
536    /// `enabled = false`; with explicit overrides, custom values appear.
537    /// Test: itself.
538    #[tokio::test]
539    async fn config_from_env_defaults() {
540        // Serialise with the commands::env_test_lock so this test cannot race
541        // the env-touching integration tests in `commands::prompt_context`
542        // / `commands::inbox_check`.
543        let _guard = crate::commands::env_test_lock().lock().await;
544        let tmp = tempfile::tempdir().expect("tempdir");
545        // Snapshot and clear so the test doesn't observe contamination from
546        // other tests in the same process.
547        let prev_enabled = std::env::var(ENV_ENABLED).ok();
548        let prev_dir = std::env::var(ENV_DIR).ok();
549        let prev_max = std::env::var(ENV_MAX_BYTES).ok();
550        let prev_ret = std::env::var(ENV_RETENTION_DAYS).ok();
551        let prev_hash = std::env::var(ENV_HASH_PROMPTS).ok();
552        // SAFETY: env mutation. Restored at end of test.
553        unsafe {
554            std::env::remove_var(ENV_ENABLED);
555            std::env::remove_var(ENV_DIR);
556            std::env::remove_var(ENV_MAX_BYTES);
557            std::env::remove_var(ENV_RETENTION_DAYS);
558            std::env::remove_var(ENV_HASH_PROMPTS);
559        }
560        let cfg = PromptLogConfig::from_env_with_root(tmp.path());
561        assert!(cfg.enabled);
562        assert_eq!(cfg.dir, tmp.path().join("logs"));
563        assert_eq!(cfg.max_bytes, DEFAULT_MAX_BYTES);
564        assert_eq!(cfg.retention_days, DEFAULT_RETENTION_DAYS);
565        assert!(!cfg.hash_prompts);
566        // Restore.
567        unsafe {
568            for (k, v) in [
569                (ENV_ENABLED, prev_enabled),
570                (ENV_DIR, prev_dir),
571                (ENV_MAX_BYTES, prev_max),
572                (ENV_RETENTION_DAYS, prev_ret),
573                (ENV_HASH_PROMPTS, prev_hash),
574            ] {
575                if let Some(val) = v {
576                    std::env::set_var(k, val);
577                } else {
578                    std::env::remove_var(k);
579                }
580            }
581        }
582    }
583
584    /// Why: every value of the off-switch must produce a disabled logger.
585    #[test]
586    fn is_off_matches_documented_values() {
587        for v in ["0", "off", "OFF", "Off", "false", "False", "no", "disabled"] {
588            assert!(is_off(v), "{v} should be parsed as off");
589        }
590        for v in ["1", "on", "true", "yes", "yeah", ""] {
591            assert!(!is_off(v), "{v} should NOT be parsed as off");
592        }
593    }
594
595    /// Why: hash-mode toggle has its own truthiness set.
596    #[test]
597    fn is_on_matches_documented_values() {
598        for v in ["1", "on", "ON", "true", "True", "yes", "enabled"] {
599            assert!(is_on(v), "{v} should be parsed as on");
600        }
601        for v in ["0", "off", "false", "no", ""] {
602            assert!(!is_on(v), "{v} should NOT be parsed as on");
603        }
604    }
605
606    /// Why: the filename parser is the linchpin of retention. Pin its
607    /// recognised shapes so retention can't accidentally start deleting
608    /// random files.
609    #[test]
610    fn parse_filename_date_parses_canonical_and_rotated() {
611        let canonical = "enriched-prompts.2026-05-25.jsonl";
612        let rotated = "enriched-prompts.2026-05-25.3.jsonl";
613        let canonical_date = parse_log_filename_date(canonical).expect("canonical parses");
614        let rotated_date = parse_log_filename_date(rotated).expect("rotated parses");
615        assert_eq!(canonical_date, rotated_date);
616        assert_eq!(
617            canonical_date,
618            NaiveDate::from_ymd_opt(2026, 5, 25).unwrap()
619        );
620
621        for bad in [
622            "not-our-log.txt",
623            "enriched-prompts..jsonl",
624            "enriched-prompts.bogus.jsonl",
625            "enriched-prompts.2026-13-99.jsonl",
626            "enriched-prompts.2026-05-25.txt",
627            "other-prefix.2026-05-25.jsonl",
628        ] {
629            assert!(
630                parse_log_filename_date(bad).is_none(),
631                "should not parse: {bad}"
632            );
633        }
634    }
635}