thndrs_lib/cli/input/
history.rs1use std::collections::VecDeque;
8use std::fs::{File, OpenOptions};
9use std::io::{self, Write};
10use std::path::{Path, PathBuf};
11
12use serde::{Deserialize, Serialize};
13
14use crate::utils::datetime;
15
16const SCHEMA_VERSION: u32 = 1;
17const HISTORY_FILE_NAME: &str = "input-history.jsonl";
18const HISTORY_LOCK_FILE_NAME: &str = "input-history.jsonl.lock";
19const HISTORY_MAX_BYTES: usize = 1024 * 1024;
20const HISTORY_SOFT_BYTES: usize = 768 * 1024;
21
22pub const INPUT_HISTORY_LIMIT: usize = 200;
23
24#[derive(Debug)]
25pub struct InputHistoryStore {
26 path: PathBuf,
27 lock_path: PathBuf,
28 max_bytes: usize,
29 soft_bytes: usize,
30}
31
32#[derive(Clone, Debug, Deserialize, Serialize)]
33struct InputHistoryRecord {
34 schema_version: u32,
35 session_id: String,
36 time: String,
37 text: String,
38}
39
40impl InputHistoryRecord {
41 fn new(session_id: &str, text: &str) -> Self {
42 Self {
43 schema_version: SCHEMA_VERSION,
44 session_id: session_id.to_string(),
45 time: datetime::now_iso8601(),
46 text: text.to_string(),
47 }
48 }
49}
50
51struct InputHistoryLock {
52 path: PathBuf,
53 _file: File,
55}
56
57impl InputHistoryStore {
58 pub fn for_workspace(workspace_root: &Path) -> Self {
59 let dir = workspace_root.join(".thndrs");
60 Self {
61 path: dir.join(HISTORY_FILE_NAME),
62 lock_path: dir.join(HISTORY_LOCK_FILE_NAME),
63 max_bytes: HISTORY_MAX_BYTES,
64 soft_bytes: HISTORY_SOFT_BYTES,
65 }
66 }
67
68 pub fn load_recent(&self) -> io::Result<Option<Vec<String>>> {
71 let content = match std::fs::read_to_string(&self.path) {
72 Ok(content) => content,
73 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
74 Err(error) => return Err(error),
75 };
76 Ok(Some(recent_texts(&content)))
77 }
78
79 pub fn append(&self, session_id: &str, text: &str) -> io::Result<()> {
83 let _lock = self.acquire_lock()?;
84 self.ensure_parent()?;
85
86 let record = InputHistoryRecord::new(session_id, text);
87 let mut line = serde_json::to_string(&record).map_err(io::Error::other)?;
88 line.push('\n');
89 let mut options = OpenOptions::new();
90 options.create(true).append(true);
91 #[cfg(unix)]
92 {
93 use std::os::unix::fs::OpenOptionsExt;
94 options.mode(0o600);
95 }
96 let mut file = options.open(&self.path)?;
97 ensure_owner_only_permissions(&file)?;
98 file.write_all(line.as_bytes())?;
99 file.flush()?;
100
101 if file.metadata()?.len() > self.max_bytes as u64 {
102 self.compact()?;
103 }
104 Ok(())
105 }
106
107 fn compact(&self) -> io::Result<()> {
108 let content = std::fs::read_to_string(&self.path)?;
109 let records = parsed_records(&content);
110 let retained = retain_newest_records(records, self.soft_bytes);
111 self.rewrite_records(retained)
112 }
113
114 fn rewrite_records(&self, records: impl IntoIterator<Item = InputHistoryRecord>) -> io::Result<()> {
115 let mut options = OpenOptions::new();
116 options.create(true).truncate(true).write(true);
117 #[cfg(unix)]
118 {
119 use std::os::unix::fs::OpenOptionsExt;
120 options.mode(0o600);
121 }
122 let mut file = options.open(&self.path)?;
123 ensure_owner_only_permissions(&file)?;
124 for record in records {
125 let mut line = serde_json::to_string(&record).map_err(io::Error::other)?;
126 line.push('\n');
127 file.write_all(line.as_bytes())?;
128 }
129 file.flush()
130 }
131
132 fn ensure_parent(&self) -> io::Result<()> {
133 let Some(parent) = self.path.parent() else {
134 return Err(io::Error::new(
135 io::ErrorKind::InvalidInput,
136 "input history path has no parent",
137 ));
138 };
139 std::fs::create_dir_all(parent)
140 }
141
142 fn acquire_lock(&self) -> io::Result<InputHistoryLock> {
143 self.ensure_parent()?;
144 let file = OpenOptions::new()
145 .write(true)
146 .create_new(true)
147 .open(&self.lock_path)
148 .map_err(|error| {
149 if error.kind() == io::ErrorKind::AlreadyExists {
150 io::Error::new(io::ErrorKind::WouldBlock, "input history has an active writer")
151 } else {
152 error
153 }
154 })?;
155 Ok(InputHistoryLock { path: self.lock_path.clone(), _file: file })
156 }
157
158 #[cfg(test)]
159 fn with_limits(path: PathBuf, max_bytes: usize, soft_bytes: usize) -> Self {
160 let lock_path = path.with_file_name(HISTORY_LOCK_FILE_NAME);
161 Self { path, lock_path, max_bytes, soft_bytes }
162 }
163}
164
165impl Drop for InputHistoryLock {
166 fn drop(&mut self) {
167 let _ = std::fs::remove_file(&self.path);
168 }
169}
170
171fn parsed_records(content: &str) -> Vec<InputHistoryRecord> {
172 content
173 .lines()
174 .filter_map(|line| serde_json::from_str::<InputHistoryRecord>(line).ok())
175 .filter(|record| record.schema_version == SCHEMA_VERSION && !record.text.trim().is_empty())
176 .collect()
177}
178
179fn recent_texts(content: &str) -> Vec<String> {
180 let mut recent = VecDeque::with_capacity(INPUT_HISTORY_LIMIT);
181 for record in parsed_records(content) {
182 if recent.back().is_some_and(|text| text == &record.text) {
183 continue;
184 }
185 if recent.len() == INPUT_HISTORY_LIMIT {
186 recent.pop_front();
187 }
188 recent.push_back(record.text);
189 }
190 recent.into_iter().collect()
191}
192
193fn retain_newest_records(records: Vec<InputHistoryRecord>, soft_bytes: usize) -> Vec<InputHistoryRecord> {
194 let mut retained = Vec::new();
195 let mut retained_bytes = 0usize;
196 for record in records.into_iter().rev() {
197 let record_bytes = serde_json::to_vec(&record).map_or(0, |line| line.len() + 1);
198 if !retained.is_empty()
199 && (retained.len() >= INPUT_HISTORY_LIMIT || retained_bytes.saturating_add(record_bytes) > soft_bytes)
200 {
201 break;
202 }
203 retained_bytes = retained_bytes.saturating_add(record_bytes);
204 retained.push(record);
205 }
206 retained.reverse();
207 retained
208}
209
210#[cfg(unix)]
211fn ensure_owner_only_permissions(file: &File) -> io::Result<()> {
212 use std::os::unix::fs::PermissionsExt;
213
214 let metadata = file.metadata()?;
215 if metadata.permissions().mode() & 0o777 != 0o600 {
216 let mut permissions = metadata.permissions();
217 permissions.set_mode(0o600);
218 file.set_permissions(permissions)?;
219 }
220 Ok(())
221}
222
223#[cfg(not(unix))]
224fn ensure_owner_only_permissions(_file: &File) -> io::Result<()> {
225 Ok(())
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 #[test]
233 fn append_and_load_recent_preserves_order_and_collapses_dupes() {
234 let dir = tempfile::tempdir().expect("temp dir");
235 let store = InputHistoryStore::with_limits(dir.path().join(HISTORY_FILE_NAME), 4096, 3072);
236
237 store.append("session-a", "first").expect("append first");
238 store.append("session-a", "first").expect("append duplicate");
239 store.append("session-b", "second").expect("append second");
240
241 assert_eq!(
242 store.load_recent().expect("load history"),
243 Some(vec!["first".to_string(), "second".to_string()])
244 );
245 }
246
247 #[test]
248 fn compaction_keeps_newest_records_within_soft_cap() {
249 let dir = tempfile::tempdir().expect("temp dir");
250 let store = InputHistoryStore::with_limits(dir.path().join(HISTORY_FILE_NAME), 350, 220);
251
252 for index in 0..8 {
253 store
254 .append("session", &format!("prompt {index} {}", "x".repeat(60)))
255 .expect("append prompt");
256 }
257
258 let history = store.load_recent().expect("load history").expect("history exists");
259 assert!(history.last().is_some_and(|text| text.starts_with("prompt 7")));
260 assert!(std::fs::metadata(&store.path).expect("history metadata").len() <= 350);
261 }
262
263 #[test]
264 fn malformed_records_are_ignored() {
265 let valid = serde_json::to_string(&InputHistoryRecord::new("session", "valid")).expect("serialize record");
266 let content = format!("not json\n{valid}\n");
267
268 assert_eq!(recent_texts(&content), vec!["valid".to_string()]);
269 }
270
271 #[cfg(unix)]
272 #[test]
273 fn history_file_is_owner_only() {
274 use std::os::unix::fs::PermissionsExt;
275
276 let dir = tempfile::tempdir().expect("temp dir");
277 let store = InputHistoryStore::with_limits(dir.path().join(HISTORY_FILE_NAME), 4096, 3072);
278 store.append("session", "private prompt").expect("append prompt");
279
280 let mode = std::fs::metadata(&store.path)
281 .expect("history metadata")
282 .permissions()
283 .mode()
284 & 0o777;
285 assert_eq!(mode, 0o600);
286 }
287}