1use 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
21const FILE_PREFIX: &str = "enriched-prompts";
23const FILE_EXT: &str = "jsonl";
25
26#[derive(Clone, Debug)]
45pub struct PromptLogger {
46 config: PromptLogConfig,
47}
48
49impl PromptLogger {
50 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 pub fn from_config(config: PromptLogConfig) -> Self {
71 Self { config }
72 }
73
74 pub fn config(&self) -> &PromptLogConfig {
76 &self.config
77 }
78
79 pub fn log(&self, entry: PromptLogEntry) {
90 if !self.config.enabled {
91 return;
92 }
93
94 let entry = self.apply_privacy(entry);
96
97 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 self.prune_if_needed();
109
110 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 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 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 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 Ok(path_for(u32::MAX))
193 }
194
195 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
239fn 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 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
260pub(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 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 #[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 #[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 #[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 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 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 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 #[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 assert!(
490 !tmp.path().join("logs").exists(),
491 "disabled logger must not create the log directory"
492 );
493 }
494
495 #[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 assert_eq!(parsed.trigger_prompt, hash_prompt(raw_prompt));
531 }
532
533 #[tokio::test]
539 async fn config_from_env_defaults() {
540 let _guard = crate::commands::env_test_lock().lock().await;
544 let tmp = tempfile::tempdir().expect("tempdir");
545 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 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 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 #[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 #[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 #[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}