1pub fn save_tee(command: &str, output: &str) -> Option<String> {
10 let tee_dir = crate::core::paths::state_dir().ok()?.join("tee");
11 std::fs::create_dir_all(&tee_dir).ok()?;
12
13 if let Ok(now) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
20 && TEE_CLEANUP_THROTTLE.is_due(now.as_secs())
21 {
22 cleanup_old_tee_logs(&tee_dir);
23 }
24
25 let cmd_slug: String = command
26 .chars()
27 .take(40)
28 .map(|c| {
29 if c.is_alphanumeric() || c == '-' {
30 c
31 } else {
32 '_'
33 }
34 })
35 .collect();
36 let cmd_hash = blake3::hash(command.as_bytes()).to_hex();
41 let filename = format!("{cmd_slug}_{}.log", &cmd_hash.as_str()[..8]);
42 let path = tee_dir.join(&filename);
43
44 let masked = crate::core::redaction::redact_text(output);
45 let (redacted, _) = crate::core::secret_detection::scan_and_redact_from_config(&masked);
46 std::fs::write(&path, redacted).ok()?;
47 #[cfg(unix)]
48 {
49 use std::os::unix::fs::PermissionsExt;
50 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
51 }
52 let handle = path.to_string_lossy().to_string();
53 crate::core::relevance_tracker::register_compressed(
54 handle.clone(),
55 output,
56 "ctx_shell",
57 crate::core::tokens::count_tokens(output),
58 0,
59 );
60 Some(handle)
61}
62
63struct CleanupThrottle {
67 last_run_unix_secs: std::sync::atomic::AtomicU64,
68 interval_secs: u64,
69}
70
71impl CleanupThrottle {
72 const fn new(interval_secs: u64) -> Self {
73 Self {
74 last_run_unix_secs: std::sync::atomic::AtomicU64::new(0),
75 interval_secs,
76 }
77 }
78
79 fn is_due(&self, now_unix_secs: u64) -> bool {
82 use std::sync::atomic::Ordering;
83 let last = self.last_run_unix_secs.load(Ordering::Relaxed);
84 let due = last == 0 || now_unix_secs.saturating_sub(last) >= self.interval_secs;
85 due && self
86 .last_run_unix_secs
87 .compare_exchange(last, now_unix_secs, Ordering::Relaxed, Ordering::Relaxed)
88 .is_ok()
89 }
90}
91
92static TEE_CLEANUP_THROTTLE: CleanupThrottle = CleanupThrottle::new(10 * 60);
93
94pub(crate) fn cleanup_old_tee_logs(tee_dir: &std::path::Path) {
98 let cutoff = std::time::SystemTime::now().checked_sub(std::time::Duration::from_hours(24));
99 let Some(cutoff) = cutoff else { return };
100
101 if let Ok(entries) = std::fs::read_dir(tee_dir) {
102 for entry in entries.flatten() {
103 if let Ok(meta) = entry.metadata()
104 && let Ok(modified) = meta.modified()
105 && modified < cutoff
106 {
107 let _ = std::fs::remove_file(entry.path());
108 }
109 }
110 }
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 #[test]
120 fn cleanup_throttle_gates_by_interval() {
121 let throttle = CleanupThrottle::new(600);
122 assert!(
123 throttle.is_due(1_000),
124 "never run before: first call is always due"
125 );
126 assert!(
127 !throttle.is_due(1_100),
128 "only 100s elapsed of a 600s interval"
129 );
130 assert!(!throttle.is_due(1_599), "still short of the interval by 1s");
131 assert!(
132 throttle.is_due(1_600),
133 "exactly interval-elapsed must be due again"
134 );
135 }
136
137 #[test]
138 fn cleanup_throttle_lets_only_one_racer_through_per_interval() {
139 let throttle = CleanupThrottle::new(600);
140 let hits = std::thread::scope(|scope| {
141 let handles: Vec<_> = (0..16)
142 .map(|_| scope.spawn(|| throttle.is_due(1_000)))
143 .collect();
144 handles
145 .into_iter()
146 .map(|h| h.join().unwrap())
147 .filter(|&due| due)
148 .count()
149 });
150 assert_eq!(hits, 1, "exactly one racer should win the cleanup slot");
151 }
152
153 #[test]
157 fn tee_path_is_content_addressed() {
158 let _lock = crate::core::data_dir::test_env_lock();
161 let first = save_tee("cargo test --lib", "output run 1").expect("tee saved");
162 let second = save_tee("cargo test --lib", "output run 2").expect("tee saved");
163 assert_eq!(first, second, "same command must map to the same tee path");
164
165 let other = save_tee("cargo build", "output").expect("tee saved");
166 assert_ne!(first, other, "different commands get different tee paths");
167
168 let content = std::fs::read_to_string(&second).unwrap();
170 assert!(content.contains("run 2"));
171
172 for p in [first, other] {
173 let _ = std::fs::remove_file(p);
174 }
175 }
176}