Skip to main content

thndrs_lib/cli/input/
history.rs

1//! Workspace-local prompt history persistence.
2//!
3//! Prompt recall has different access patterns from full session replay
4//! for performance, so it uses one small append-only JSONL file instead
5//! of scanning every session.
6
7use 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    // TODO: why is this stored if its not read?
54    _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    /// Load the newest valid entries, or `None` when no dedicated history has
69    /// been created yet.
70    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    /// Append one submitted prompt and compact when the hard byte cap is
80    /// exceeded. The newest prompt is retained even when it alone exceeds the
81    /// configured soft cap.
82    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}