reedline/history/
file_backed.rs

1use super::{
2    base::CommandLineSearch, History, HistoryItem, HistoryItemId, SearchDirection, SearchQuery,
3};
4use crate::{
5    result::{ReedlineError, ReedlineErrorVariants},
6    HistorySessionId, Result,
7};
8
9use std::{
10    collections::VecDeque,
11    fs::OpenOptions,
12    io::{BufRead, BufReader, BufWriter, Seek, SeekFrom, Write},
13    ops::{Deref, DerefMut},
14    path::PathBuf,
15};
16
17/// Default size of the [`FileBackedHistory`] used when calling [`FileBackedHistory::default()`]
18pub const HISTORY_SIZE: usize = 1000;
19pub const NEWLINE_ESCAPE: &str = "<\\n>";
20
21/// Stateful history that allows up/down-arrow browsing with an internal cursor.
22///
23/// Can optionally be associated with a newline separated history file using the [`FileBackedHistory::with_file()`] constructor.
24/// Similar to bash's behavior without HISTTIMEFORMAT.
25/// (See <https://www.gnu.org/software/bash/manual/html_node/Bash-History-Facilities.html>)
26/// If the history is associated to a file all new changes within a given history capacity will be written to disk when History is dropped.
27#[derive(Debug)]
28pub struct FileBackedHistory {
29    capacity: usize,
30    entries: VecDeque<String>,
31    file: Option<PathBuf>,
32    len_on_disk: usize, // Keep track what was previously written to disk
33    session: Option<HistorySessionId>,
34}
35
36impl Default for FileBackedHistory {
37    /// Creates an in-memory [`History`] with a maximal capacity of [`HISTORY_SIZE`].
38    ///
39    /// To create a [`History`] that is synchronized with a file use [`FileBackedHistory::with_file()`]
40    fn default() -> Self {
41        Self::new(HISTORY_SIZE)
42    }
43}
44
45fn encode_entry(s: &str) -> String {
46    s.replace('\n', NEWLINE_ESCAPE)
47}
48
49fn decode_entry(s: &str) -> String {
50    s.replace(NEWLINE_ESCAPE, "\n")
51}
52
53impl History for FileBackedHistory {
54    /// only saves a value if it's different than the last value
55    fn save(&mut self, h: HistoryItem) -> Result<HistoryItem> {
56        let entry = h.command_line;
57        // Don't append if the preceding value is identical or the string empty
58        let entry_id = if self
59            .entries
60            .back()
61            .map_or(true, |previous| previous != &entry)
62            && !entry.is_empty()
63        {
64            if self.entries.len() == self.capacity {
65                // History is "full", so we delete the oldest entry first,
66                // before adding a new one.
67                self.entries.pop_front();
68                self.len_on_disk = self.len_on_disk.saturating_sub(1);
69            }
70            self.entries.push_back(entry.to_string());
71            Some(HistoryItemId::new((self.entries.len() - 1) as i64))
72        } else {
73            None
74        };
75        Ok(FileBackedHistory::construct_entry(entry_id, entry))
76    }
77
78    fn load(&self, id: HistoryItemId) -> Result<super::HistoryItem> {
79        Ok(FileBackedHistory::construct_entry(
80            Some(id),
81            self.entries
82                .get(id.0 as usize)
83                .ok_or(ReedlineError(ReedlineErrorVariants::OtherHistoryError(
84                    "Item does not exist",
85                )))?
86                .clone(),
87        ))
88    }
89
90    fn count(&self, query: SearchQuery) -> Result<i64> {
91        // todo: this could be done cheaper
92        Ok(self.search(query)?.len() as i64)
93    }
94
95    fn search(&self, query: SearchQuery) -> Result<Vec<HistoryItem>> {
96        if query.start_time.is_some() || query.end_time.is_some() {
97            return Err(ReedlineError(
98                ReedlineErrorVariants::HistoryFeatureUnsupported {
99                    history: "FileBackedHistory",
100                    feature: "filtering by time",
101                },
102            ));
103        }
104
105        if query.filter.hostname.is_some()
106            || query.filter.cwd_exact.is_some()
107            || query.filter.cwd_prefix.is_some()
108            || query.filter.exit_successful.is_some()
109        {
110            return Err(ReedlineError(
111                ReedlineErrorVariants::HistoryFeatureUnsupported {
112                    history: "FileBackedHistory",
113                    feature: "filtering by extra info",
114                },
115            ));
116        }
117        let (min_id, max_id) = {
118            let start = query.start_id.map(|e| e.0);
119            let end = query.end_id.map(|e| e.0);
120            if let SearchDirection::Backward = query.direction {
121                (end, start)
122            } else {
123                (start, end)
124            }
125        };
126        // add one to make it inclusive
127        let min_id = min_id.map(|e| e + 1).unwrap_or(0);
128        // subtract one to make it inclusive
129        let max_id = max_id
130            .map(|e| e - 1)
131            .unwrap_or(self.entries.len() as i64 - 1);
132        if max_id < 0 || min_id > self.entries.len() as i64 - 1 {
133            return Ok(vec![]);
134        }
135        let intrinsic_limit = max_id - min_id + 1;
136        let limit = if let Some(given_limit) = query.limit {
137            std::cmp::min(intrinsic_limit, given_limit) as usize
138        } else {
139            intrinsic_limit as usize
140        };
141        let filter = |(idx, cmd): (usize, &String)| {
142            if !match &query.filter.command_line {
143                Some(CommandLineSearch::Prefix(p)) => cmd.starts_with(p),
144                Some(CommandLineSearch::Substring(p)) => cmd.contains(p),
145                Some(CommandLineSearch::Exact(p)) => cmd == p,
146                None => true,
147            } {
148                return None;
149            }
150            if let Some(str) = &query.filter.not_command_line {
151                if cmd == str {
152                    return None;
153                }
154            }
155            Some(FileBackedHistory::construct_entry(
156                Some(HistoryItemId::new(idx as i64)),
157                cmd.to_string(), // todo: this copy might be a perf bottleneck
158            ))
159        };
160
161        let iter = self
162            .entries
163            .iter()
164            .enumerate()
165            .skip(min_id as usize)
166            .take(intrinsic_limit as usize);
167        if let SearchDirection::Backward = query.direction {
168            Ok(iter.rev().filter_map(filter).take(limit).collect())
169        } else {
170            Ok(iter.filter_map(filter).take(limit).collect())
171        }
172    }
173
174    fn update(
175        &mut self,
176        _id: super::HistoryItemId,
177        _updater: &dyn Fn(super::HistoryItem) -> super::HistoryItem,
178    ) -> Result<()> {
179        Err(ReedlineError(
180            ReedlineErrorVariants::HistoryFeatureUnsupported {
181                history: "FileBackedHistory",
182                feature: "updating entries",
183            },
184        ))
185    }
186
187    fn clear(&mut self) -> Result<()> {
188        self.entries.clear();
189        self.len_on_disk = 0;
190
191        if let Some(file) = &self.file {
192            if let Err(err) = std::fs::remove_file(file) {
193                return Err(ReedlineError(ReedlineErrorVariants::IOError(err)));
194            }
195        }
196
197        Ok(())
198    }
199
200    fn delete(&mut self, _h: super::HistoryItemId) -> Result<()> {
201        Err(ReedlineError(
202            ReedlineErrorVariants::HistoryFeatureUnsupported {
203                history: "FileBackedHistory",
204                feature: "removing entries",
205            },
206        ))
207    }
208
209    /// Writes unwritten history contents to disk.
210    ///
211    /// If file would exceed `capacity` truncates the oldest entries.
212    fn sync(&mut self) -> std::io::Result<()> {
213        if let Some(fname) = &self.file {
214            // The unwritten entries
215            let own_entries = self.entries.range(self.len_on_disk..);
216
217            if let Some(base_dir) = fname.parent() {
218                std::fs::create_dir_all(base_dir)?;
219            }
220
221            let mut f_lock = fd_lock::RwLock::new(
222                OpenOptions::new()
223                    .create(true)
224                    .write(true)
225                    .read(true)
226                    .open(fname)?,
227            );
228            let mut writer_guard = f_lock.write()?;
229            let (mut foreign_entries, truncate) = {
230                let reader = BufReader::new(writer_guard.deref());
231                let mut from_file = reader
232                    .lines()
233                    .map(|o| o.map(|i| decode_entry(&i)))
234                    .collect::<std::io::Result<VecDeque<_>>>()?;
235                if from_file.len() + own_entries.len() > self.capacity {
236                    (
237                        from_file.split_off(from_file.len() - (self.capacity - own_entries.len())),
238                        true,
239                    )
240                } else {
241                    (from_file, false)
242                }
243            };
244
245            {
246                let mut writer = BufWriter::new(writer_guard.deref_mut());
247                if truncate {
248                    writer.rewind()?;
249
250                    for line in &foreign_entries {
251                        writer.write_all(encode_entry(line).as_bytes())?;
252                        writer.write_all("\n".as_bytes())?;
253                    }
254                } else {
255                    writer.seek(SeekFrom::End(0))?;
256                }
257                for line in own_entries {
258                    writer.write_all(encode_entry(line).as_bytes())?;
259                    writer.write_all("\n".as_bytes())?;
260                }
261                writer.flush()?;
262            }
263            if truncate {
264                let file = writer_guard.deref_mut();
265                let file_len = file.stream_position()?;
266                file.set_len(file_len)?;
267            }
268
269            let own_entries = self.entries.drain(self.len_on_disk..);
270            foreign_entries.extend(own_entries);
271            self.entries = foreign_entries;
272
273            self.len_on_disk = self.entries.len();
274        }
275        Ok(())
276    }
277
278    fn session(&self) -> Option<HistorySessionId> {
279        self.session
280    }
281}
282
283impl FileBackedHistory {
284    /// Creates a new in-memory history that remembers `n <= capacity` elements
285    ///
286    /// # Panics
287    ///
288    /// If `capacity == usize::MAX`
289    pub fn new(capacity: usize) -> Self {
290        if capacity == usize::MAX {
291            panic!("History capacity too large to be addressed safely");
292        }
293        FileBackedHistory {
294            capacity,
295            entries: VecDeque::new(),
296            file: None,
297            len_on_disk: 0,
298            session: None,
299        }
300    }
301
302    /// Creates a new history with an associated history file.
303    ///
304    /// History file format: commands separated by new lines.
305    /// If file exists file will be read otherwise empty file will be created.
306    ///
307    ///
308    /// **Side effects:** creates all nested directories to the file
309    ///
310    pub fn with_file(capacity: usize, file: PathBuf) -> std::io::Result<Self> {
311        let mut hist = Self::new(capacity);
312        if let Some(base_dir) = file.parent() {
313            std::fs::create_dir_all(base_dir)?;
314        }
315        hist.file = Some(file);
316        hist.sync()?;
317        Ok(hist)
318    }
319
320    // this history doesn't store any info except command line
321    fn construct_entry(id: Option<HistoryItemId>, command_line: String) -> HistoryItem {
322        HistoryItem {
323            id,
324            start_timestamp: None,
325            command_line,
326            session_id: None,
327            hostname: None,
328            cwd: None,
329            duration: None,
330            exit_status: None,
331            more_info: None,
332        }
333    }
334}
335
336impl Drop for FileBackedHistory {
337    /// On drop the content of the [`History`] will be written to the file if specified via [`FileBackedHistory::with_file()`].
338    fn drop(&mut self) {
339        let _res = self.sync();
340    }
341}