Skip to main content

brush_core/
history.rs

1//! Facilities for tracking and persisting the shell's command history.
2
3use chrono::Utc;
4use std::{
5    io::{BufRead, Read, Write},
6    path::Path,
7};
8
9use crate::error;
10
11/// Represents a unique identifier for a history item.
12type ItemId = i64;
13
14/// Interface for querying and manipulating the shell's recorded history of commands.
15// TODO(history): support maximum item count
16#[derive(Clone, Default)]
17#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
18pub struct History {
19    items: rpds::VectorSync<ItemId>,
20    id_map: rpds::HashTrieMapSync<ItemId, Item>,
21    next_id: ItemId,
22}
23
24impl History {
25    /// Constructs a new `History` instance, with its contents initialized from the given readable
26    /// stream. If errors are encountered reading lines from the stream, unreadable lines will
27    /// be skipped but the call will still return successfully, with a warning logged. An error
28    /// result will be returned only if an internal error occurs updating the history.
29    ///
30    /// # Arguments
31    ///
32    /// * `reader` - The readable stream to import history from.
33    pub fn import(reader: impl Read) -> Result<Self, error::Error> {
34        let mut history = Self::default();
35
36        let buf_reader = std::io::BufReader::new(reader);
37
38        let mut next_timestamp = None;
39        for line_result in buf_reader.lines() {
40            let line = match line_result {
41                Ok(line) => line,
42                // If we couldn't decode the line due to invalid data (perhaps it wasn't
43                // valid UTF8?), skip it and make a best-effort attempt to proceed on.
44                // We'll later warn the user.
45                Err(err) if err.kind() == std::io::ErrorKind::InvalidData => {
46                    tracing::warn!("unreadable history line; {err}");
47                    continue;
48                }
49                // In the event of other kinds of errors, return an error result. We don't
50                // want to get stuck in a failing I/O loop.
51                Err(err) => {
52                    return Err(err.into());
53                }
54            };
55
56            // Look for timestamp comments; ignore other comment lines.
57            if let Some(comment) = line.strip_prefix("#") {
58                if let Ok(seconds_since_epoch) = comment.trim().parse() {
59                    next_timestamp = ItemTimestamp::from_timestamp(seconds_since_epoch, 0);
60                } else {
61                    next_timestamp = None;
62                }
63
64                continue;
65            }
66
67            let item = Item {
68                id: history.next_id,
69                command_line: line,
70                timestamp: next_timestamp.take(),
71                dirty: false,
72            };
73
74            history.add(item)?;
75        }
76
77        Ok(history)
78    }
79
80    /// Tries to retrieve a history item by its unique identifier. Returns `None` if no item is
81    /// found.
82    ///
83    /// # Arguments
84    ///
85    /// * `id` - The unique identifier of the history item to retrieve.
86    pub fn get_by_id(&self, id: ItemId) -> Result<Option<&Item>, error::Error> {
87        Ok(self.id_map.get(&id))
88    }
89
90    /// Replaces the history item with the given ID with a new item. Returns an error if the item
91    /// cannot be updated.
92    ///
93    /// # Arguments
94    ///
95    /// * `id` - The unique identifier of the history item to update.
96    /// * `item` - The new history item to replace the old one.
97    pub fn update_by_id(&mut self, id: ItemId, item: Item) -> Result<(), error::Error> {
98        let existing_item = self
99            .id_map
100            .get_mut(&id)
101            .ok_or(error::ErrorKind::HistoryItemNotFound)?;
102        *existing_item = item;
103        Ok(())
104    }
105
106    /// Removes the nth item from the history. Returns the removed item, or `None` if no such item
107    /// exists (i.e., because it was out of range).
108    pub fn remove_nth_item(&mut self, n: usize) -> bool {
109        if let Some(id) = self.items.get(n).copied() {
110            self.items = self
111                .items
112                .into_iter()
113                .enumerate()
114                .filter_map(|(i, id)| if i != n { Some(id) } else { None })
115                .copied()
116                .collect();
117
118            self.id_map.remove_mut(&id);
119
120            true
121        } else {
122            false
123        }
124    }
125
126    /// Adds a new history item. Returns the unique identifier of the newly added item.
127    ///
128    /// # Arguments
129    ///
130    /// * `item` - The history item to add.
131    pub fn add(&mut self, mut item: Item) -> Result<ItemId, error::Error> {
132        let id = self.next_id;
133
134        item.id = id;
135        self.next_id += 1;
136
137        self.items.push_back_mut(item.id);
138        self.id_map.insert_mut(item.id, item);
139
140        Ok(id)
141    }
142
143    /// Deletes a history item by its unique identifier. Returns an error if the item cannot be
144    /// deleted.
145    ///
146    /// # Arguments
147    ///
148    /// * `id` - The unique identifier of the history item to delete.
149    pub fn delete_item_by_id(&mut self, id: ItemId) -> Result<(), error::Error> {
150        self.id_map.remove_mut(&id);
151        self.items = self
152            .items
153            .into_iter()
154            .filter(|&item_id| *item_id != id)
155            .copied()
156            .collect();
157
158        Ok(())
159    }
160
161    /// Clears all history items.
162    pub fn clear(&mut self) -> Result<(), error::Error> {
163        self.id_map = rpds::HashTrieMapSync::new_sync();
164        self.items = rpds::VectorSync::new_sync();
165        Ok(())
166    }
167
168    /// Flushes the history to backing storage (if relevant).
169    ///
170    /// # Arguments
171    ///
172    /// * `history_file_path` - The path to the history file.
173    /// * `append` - Whether to append to the file or overwrite it.
174    /// * `unsaved_items_only` - Whether to only write unsaved items; if true, any items will be
175    ///   marked as "saved" once saved.
176    /// * `write_timestamps` - Whether to write timestamps for each command line.
177    pub fn flush(
178        &mut self,
179        history_file_path: impl AsRef<Path>,
180        append: bool,
181        unsaved_items_only: bool,
182        write_timestamps: bool,
183    ) -> Result<(), error::Error> {
184        // Open the file
185        let mut file_options = std::fs::File::options();
186
187        if append {
188            file_options.append(true);
189        } else {
190            file_options.write(true).truncate(true);
191        }
192
193        let mut file = file_options.create(true).open(history_file_path.as_ref())?;
194
195        for item_id in &self.items {
196            if let Some(item) = self.id_map.get_mut(item_id) {
197                if unsaved_items_only && !item.dirty {
198                    continue;
199                }
200
201                if write_timestamps && let Some(timestamp) = item.timestamp {
202                    writeln!(file, "#{}", timestamp.timestamp())?;
203                }
204
205                writeln!(file, "{}", item.command_line)?;
206
207                if unsaved_items_only {
208                    item.dirty = false;
209                }
210            }
211        }
212
213        file.flush()?;
214
215        Ok(())
216    }
217
218    /// Searches through history using the given query.
219    ///
220    /// # Arguments
221    ///
222    /// * `query` - The query to use.
223    pub fn search(&self, query: Query) -> Result<impl Iterator<Item = &self::Item>, error::Error> {
224        Ok(Search::new(self, query))
225    }
226
227    /// Returns an iterator over the history items.
228    pub fn iter(&self) -> impl Iterator<Item = &self::Item> {
229        Search::all(self)
230    }
231
232    /// Retrieves the nth history item, if it exists. Returns `None` if no such item exists.
233    /// Indexing is zero-based, with an index of 0 referencing the oldest item in the history.
234    ///
235    /// # Arguments
236    ///
237    /// * `index` - The index of the history item to retrieve.
238    pub fn get(&self, index: usize) -> Option<&Item> {
239        if let Some(id) = self.items.get(index) {
240            self.id_map.get(id)
241        } else {
242            None
243        }
244    }
245
246    /// Returns the number of items in the history.
247    pub fn count(&self) -> usize {
248        self.items.len()
249    }
250}
251
252/// Represents a timestamp for a history item.
253pub type ItemTimestamp = chrono::DateTime<Utc>;
254
255/// Represents an item in the history.
256#[derive(Clone, Default)]
257#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
258pub struct Item {
259    /// The unique identifier of the history item.
260    pub id: ItemId,
261    /// The actual command line.
262    pub command_line: String,
263    /// The timestamp when the command was started.
264    pub timestamp: Option<ItemTimestamp>,
265    /// Whether or not the item is dirty, i.e., has not yet been written to backing storage.
266    pub dirty: bool,
267}
268
269impl Item {
270    /// Constructs a new `Item` with the given command line.
271    ///
272    /// # Arguments
273    ///
274    /// * `command_line` - The command line of the item.
275    pub fn new(command_line: impl Into<String>) -> Self {
276        Self {
277            id: 0, // NOTE: ID will be assigned when added to the history.
278            command_line: command_line.into(),
279            timestamp: Some(chrono::Utc::now()),
280            dirty: true,
281        }
282    }
283}
284
285/// Encapsulates query parameters for searching through history.
286#[derive(Default)]
287pub struct Query {
288    /// Whether to search forward or backward
289    pub direction: Direction,
290    /// Optionally, clamp results to items with a timestamp strictly after this.
291    pub not_at_or_before_time: Option<ItemTimestamp>,
292    /// Optionally, clamp results to items with a timestamp strictly before this.
293    pub not_at_or_after_time: Option<ItemTimestamp>,
294    /// Optionally, clamp results to items with an ID equal strictly after this.
295    pub not_at_or_before_id: Option<ItemId>,
296    /// Optionally, clamp results to items with an ID equal strictly before this.
297    pub not_at_or_after_id: Option<ItemId>,
298    /// Optionally, maximum number of items to retrieve
299    pub max_items: Option<i64>,
300    /// Optionally, a string-based filter on command line.
301    pub command_line_filter: Option<CommandLineFilter>,
302}
303
304impl Query {
305    /// Checks if the query includes the given item.
306    ///
307    /// # Arguments
308    ///
309    /// * `item` - The item to check.
310    pub fn includes(&self, item: &Item) -> bool {
311        // Filter based on not_at_or_before_time.
312        if let Some(not_at_or_before_time) = &self.not_at_or_before_time {
313            if item
314                .timestamp
315                .is_some_and(|ts| ts <= *not_at_or_before_time)
316            {
317                return false;
318            }
319        }
320
321        // Filter based on not_at_or_after_time
322        if let Some(not_at_or_after_time) = &self.not_at_or_after_time {
323            if item.timestamp.is_some_and(|ts| ts >= *not_at_or_after_time) {
324                return false;
325            }
326        }
327
328        // Filter based on not_at_or_before_id
329        if self
330            .not_at_or_before_id
331            .is_some_and(|query_id| item.id <= query_id)
332        {
333            return false;
334        }
335
336        // Filter based on not_at_or_after_id
337        if self
338            .not_at_or_after_id
339            .is_some_and(|query_id| item.id >= query_id)
340        {
341            return false;
342        }
343
344        // Filter based on command_line_filter
345        if let Some(command_line_filter) = &self.command_line_filter {
346            match command_line_filter {
347                CommandLineFilter::Prefix(prefix) => {
348                    if !item.command_line.starts_with(prefix) {
349                        return false;
350                    }
351                }
352                CommandLineFilter::Suffix(suffix) => {
353                    if !item.command_line.ends_with(suffix) {
354                        return false;
355                    }
356                }
357                CommandLineFilter::Contains(contains) => {
358                    if !item.command_line.contains(contains) {
359                        return false;
360                    }
361                }
362                CommandLineFilter::Exact(exact) => {
363                    if item.command_line != *exact {
364                        return false;
365                    }
366                }
367            }
368        }
369
370        true
371    }
372}
373
374/// Represents the direction of a search operation.
375#[derive(Default)]
376pub enum Direction {
377    /// Search forward from the oldest part of history.
378    #[default]
379    Forward,
380    /// Search backward from the youngest part of history.
381    Backward,
382}
383
384/// Filter criteria for command lines.
385pub enum CommandLineFilter {
386    /// The command line must start with this string.
387    Prefix(String),
388    /// The command line must end with this string.
389    Suffix(String),
390    /// The command line must contain this string.
391    Contains(String),
392    /// The command line must match this string exactly.
393    Exact(String),
394}
395
396/// Represents a search operation.
397pub struct Search<'a> {
398    /// The history to search through.
399    history: &'a History,
400    /// The query to apply.
401    query: Query,
402    /// The next index in `items`.
403    next_index: Option<usize>,
404    /// Count of items returned so far.
405    count: usize,
406}
407
408impl<'a> Search<'a> {
409    /// Constructs a new search against the provided history, querying *all* items.
410    ///
411    /// # Arguments
412    ///
413    /// * `history` - The history to search through.
414    pub fn all(history: &'a History) -> Self {
415        Self::new(history, Query::default())
416    }
417
418    /// Constructs a new search against the provided history, using the given query.
419    ///
420    /// # Arguments
421    ///
422    /// * `history` - The history to search through.
423    /// * `query` - The query to use.
424    pub fn new(history: &'a History, query: Query) -> Self {
425        let next_index = match query.direction {
426            Direction::Forward => Some(0),
427            Direction::Backward => {
428                if history.items.is_empty() {
429                    None
430                } else {
431                    Some(history.items.len() - 1)
432                }
433            }
434        };
435
436        Self {
437            history,
438            query,
439            next_index,
440            count: 0,
441        }
442    }
443
444    const fn increment_next_index(&mut self) {
445        if let Some(index) = self.next_index {
446            self.next_index = match self.query.direction {
447                Direction::Forward => Some(index + 1),
448                Direction::Backward => {
449                    if index == 0 {
450                        None
451                    } else {
452                        Some(index - 1)
453                    }
454                }
455            }
456        }
457    }
458}
459
460impl<'a> Iterator for Search<'a> {
461    type Item = &'a Item;
462
463    fn next(&mut self) -> Option<Self::Item> {
464        loop {
465            if let Some(index) = self.next_index {
466                // Make sure we haven't hit the end of the history.
467                if index >= self.history.items.len() {
468                    return None;
469                }
470
471                let id = self.history.items[index];
472                self.increment_next_index();
473
474                if let Some(item) = self.history.id_map.get(&id) {
475                    // Filter based on max_items. Once we hit the limit,
476                    // we stop searching.
477                    #[expect(clippy::cast_possible_truncation)]
478                    #[expect(clippy::cast_sign_loss)]
479                    if self
480                        .query
481                        .max_items
482                        .is_some_and(|max_items| self.count >= max_items as usize)
483                    {
484                        return None;
485                    }
486
487                    // Check other filters. If they don't match, then we
488                    // skip but keep searching.
489                    if self.query.includes(item) {
490                        self.count += 1;
491                        return Some(item);
492                    }
493                }
494            } else {
495                return None;
496            }
497        }
498    }
499}