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