Skip to main content

shrub_core/
history.rs

1//! Facilities for tracking and persisting the shell's command history.
2
3use std::collections::BTreeSet;
4use std::io::{BufRead, Read, Write};
5use std::path::Path;
6
7use chrono::Utc;
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
15/// commands.
16// TODO(history): support maximum item count
17#[derive(Clone, Default)]
18#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
19pub struct History {
20    /// The items, oldest first. Their IDs only increase, so they're in ID order
21    /// too, which finds an item by ID (by binary search) without a separate
22    /// map from IDs to items.
23    items: rpds::VectorSync<Item>,
24    next_id: ItemId,
25}
26
27impl History {
28    /// Constructs a new `History` instance, with its contents initialized from
29    /// the given readable stream. If errors are encountered reading lines
30    /// from the stream, unreadable lines will be skipped but the call will
31    /// still return successfully, with a warning logged. An error
32    /// result will be returned only if an internal error occurs updating the
33    /// history. Like bash, an entry the stream gives no timestamp gets the
34    /// time it's read.
35    ///
36    /// # Arguments
37    ///
38    /// * `reader` - The readable stream to import history from.
39    pub fn import(reader: impl Read) -> Result<Self, error::Error> {
40        let mut history = Self::default();
41        let read_time = Utc::now();
42
43        let buf_reader = std::io::BufReader::new(reader);
44
45        let mut next_timestamp = None;
46        for line_result in buf_reader.lines() {
47            let line = match line_result {
48                Ok(line) => line,
49                // If we couldn't decode the line due to invalid data (perhaps it wasn't
50                // valid UTF8?), skip it and make a best-effort attempt to proceed on.
51                // We'll later warn the user.
52                Err(err) if err.kind() == std::io::ErrorKind::InvalidData => {
53                    tracing::warn!("unreadable history line; {err}");
54                    continue;
55                }
56                // In the event of other kinds of errors, return an error result. We don't
57                // want to get stuck in a failing I/O loop.
58                Err(err) => {
59                    return Err(err.into());
60                }
61            };
62
63            // Look for timestamp comments; ignore other comment lines.
64            if let Some(comment) = line.strip_prefix("#") {
65                if let Ok(seconds_since_epoch) = comment.trim().parse() {
66                    next_timestamp = ItemTimestamp::from_timestamp(seconds_since_epoch, 0);
67                } else {
68                    next_timestamp = None;
69                }
70
71                continue;
72            }
73
74            let item = Item {
75                id: history.next_id,
76                command_line: line,
77                timestamp: Some(next_timestamp.take().unwrap_or(read_time)),
78                dirty: false,
79            };
80
81            history.add(item)?;
82        }
83
84        Ok(history)
85    }
86
87    /// Tries to retrieve a history item by its unique identifier. Returns
88    /// `None` if no item is found.
89    ///
90    /// # Arguments
91    ///
92    /// * `id` - The unique identifier of the history item to retrieve.
93    pub fn get_by_id(&self, id: ItemId) -> Result<Option<&Item>, error::Error> {
94        Ok(self.position_of(id).and_then(|index| self.items.get(index)))
95    }
96
97    /// Replaces the history item with the given ID with a new item. Returns an
98    /// error if the item cannot be updated.
99    ///
100    /// # Arguments
101    ///
102    /// * `id` - The unique identifier of the history item to update.
103    /// * `item` - The new history item to replace the old one.
104    pub fn update_by_id(&mut self, id: ItemId, item: Item) -> Result<(), error::Error> {
105        let index = self
106            .position_of(id)
107            .ok_or(error::ErrorKind::HistoryItemNotFound)?;
108        // The item keeps its ID, so the items stay in ID order.
109        self.items.set_mut(index, Item { id, ..item });
110        Ok(())
111    }
112
113    /// Removes the nth item from the history (counting from 0, the oldest).
114    /// Returns whether there was such an item to remove.
115    pub fn remove_nth_item(&mut self, n: usize) -> bool {
116        let len = self.items.len();
117        if n >= len {
118            return false;
119        }
120
121        // Take off the items from the nth on, and put back those after it: so
122        // removing a recent item (as `fc -s` does its own) touches only
123        // the few after it.
124        let after: Vec<Item> = (n + 1..len)
125            .filter_map(|i| self.items.get(i).cloned())
126            .collect();
127        for _ in n..len {
128            self.items.drop_last_mut();
129        }
130        for item in after {
131            self.items.push_back_mut(item);
132        }
133
134        true
135    }
136
137    /// Keeps only the items that `keep` returns true for, in order. Their IDs
138    /// don't change.
139    pub fn retain(&mut self, mut keep: impl FnMut(&Item) -> bool) {
140        let Some(first_removed) = self.items.iter().position(|item| !keep(item)) else {
141            return;
142        };
143
144        // As in `remove_nth_item`, rebuild only from the first item removed on.
145        let kept: Vec<Item> = self
146            .items
147            .iter()
148            .skip(first_removed + 1)
149            .filter(|item| keep(item))
150            .cloned()
151            .collect();
152        for _ in first_removed..self.items.len() {
153            self.items.drop_last_mut();
154        }
155        for item in kept {
156            self.items.push_back_mut(item);
157        }
158    }
159
160    /// Adds a new history item. Returns the unique identifier of the newly
161    /// added item.
162    ///
163    /// # Arguments
164    ///
165    /// * `item` - The history item to add.
166    pub fn add(&mut self, mut item: Item) -> Result<ItemId, error::Error> {
167        let id = self.next_id;
168
169        item.id = id;
170        self.next_id += 1;
171
172        self.items.push_back_mut(item);
173
174        Ok(id)
175    }
176
177    /// Deletes a history item by its unique identifier. Returns an error if the
178    /// item cannot be deleted.
179    ///
180    /// # Arguments
181    ///
182    /// * `id` - The unique identifier of the history item to delete.
183    pub fn delete_item_by_id(&mut self, id: ItemId) -> Result<(), error::Error> {
184        if let Some(index) = self.position_of(id) {
185            self.remove_nth_item(index);
186        }
187
188        Ok(())
189    }
190
191    /// Clears all history items.
192    pub fn clear(&mut self) -> Result<(), error::Error> {
193        self.items = rpds::VectorSync::new_sync();
194        Ok(())
195    }
196
197    /// Flushes the history to backing storage (if relevant).
198    ///
199    /// # Arguments
200    ///
201    /// * `history_file_path` - The path to the history file.
202    /// * `append` - Whether to append to the file or overwrite it.
203    /// * `unsaved_items_only` - Whether to only write unsaved items; if true,
204    ///   any items will be marked as "saved" once saved.
205    /// * `write_timestamps` - Whether to write timestamps for each command
206    ///   line.
207    pub fn flush(
208        &mut self,
209        history_file_path: impl AsRef<Path>,
210        append: bool,
211        unsaved_items_only: bool,
212        write_timestamps: bool,
213    ) -> Result<(), error::Error> {
214        // Open the file
215        let mut file_options = std::fs::File::options();
216
217        if append {
218            file_options.append(true);
219        } else {
220            file_options.write(true).truncate(true);
221        }
222
223        let mut file =
224            std::io::BufWriter::new(file_options.create(true).open(history_file_path.as_ref())?);
225
226        let mut written = Vec::new();
227        for (index, item) in self.items.iter().enumerate() {
228            if unsaved_items_only && !item.dirty {
229                continue;
230            }
231
232            if write_timestamps && let Some(timestamp) = item.timestamp {
233                writeln!(file, "#{}", timestamp.timestamp())?;
234            }
235
236            writeln!(file, "{}", item.command_line)?;
237
238            if unsaved_items_only {
239                written.push(index);
240            }
241        }
242
243        // Only once they're all written out, mark the items saved: if writing
244        // fails, they stay unsaved, so a later flush retries them.
245        file.into_inner()
246            .map_err(std::io::IntoInnerError::into_error)?;
247        for index in written {
248            if let Some(item) = self.items.get_mut(index) {
249                item.dirty = false;
250            }
251        }
252
253        Ok(())
254    }
255
256    /// Searches through history using the given query.
257    ///
258    /// # Arguments
259    ///
260    /// * `query` - The query to use.
261    pub fn search(&self, query: Query) -> Result<impl Iterator<Item = &self::Item>, error::Error> {
262        Ok(Search::new(self, query))
263    }
264
265    /// Returns whether the history contains no items.
266    pub fn is_empty(&self) -> bool {
267        self.items.is_empty()
268    }
269
270    /// Returns an iterator over the history items.
271    pub fn iter(&self) -> impl Iterator<Item = &self::Item> {
272        self.items.iter()
273    }
274
275    /// Retrieves the nth history item, if it exists. Returns `None` if no such
276    /// item exists. Indexing is zero-based, with an index of 0 referencing
277    /// the oldest item in the history.
278    ///
279    /// # Arguments
280    ///
281    /// * `index` - The index of the history item to retrieve.
282    pub fn get(&self, index: usize) -> Option<&Item> {
283        self.items.get(index)
284    }
285
286    /// Returns the newest item, if there is one.
287    pub fn last(&self) -> Option<&Item> {
288        self.items.last()
289    }
290
291    /// Returns the number of items in the history.
292    pub fn count(&self) -> usize {
293        self.items.len()
294    }
295
296    /// Returns how many items have IDs less than `id`: the position of the item
297    /// with that ID, if there is one.
298    fn position_before(&self, id: ItemId) -> usize {
299        // The items are in ID order, so binary search. (By hand:
300        // `slice::partition_point` needs a slice, which rpds's vector,
301        // a tree, isn't.)
302        let (mut low, mut high) = (0, self.items.len());
303        while low < high {
304            let mid = low + (high - low) / 2;
305            if self.items.get(mid).is_some_and(|item| item.id < id) {
306                low = mid + 1;
307            } else {
308                high = mid;
309            }
310        }
311        low
312    }
313
314    /// Returns the position of the item with the given ID, if there is one.
315    fn position_of(&self, id: ItemId) -> Option<usize> {
316        let index = self.position_before(id);
317        self.items
318            .get(index)
319            .is_some_and(|item| item.id == id)
320            .then_some(index)
321    }
322}
323
324/// A value of `HISTCONTROL`, which controls what an interactive shell saves to
325/// history.
326#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, strum_macros::EnumString)]
327#[strum(serialize_all = "lowercase")]
328pub(crate) enum HistoryControl {
329    /// Don't save a command that starts with a space.
330    IgnoreSpace,
331    /// Don't save a command that's the same as the last one saved.
332    IgnoreDups,
333    /// Both `ignorespace` and `ignoredups`.
334    IgnoreBoth,
335    /// Before saving a command, remove any earlier ones the same as it.
336    EraseDups,
337}
338
339impl HistoryControl {
340    /// Parses `HISTCONTROL`'s colon-separated values into the set of them, with
341    /// `ignoreboth` as the two it stands for. Like bash, values it doesn't
342    /// know are ignored.
343    pub(crate) fn parse_list(value: &str) -> BTreeSet<Self> {
344        value
345            .split(':')
346            .filter_map(|value| value.parse().ok())
347            .flat_map(|control| match control {
348                Self::IgnoreBoth => [Self::IgnoreSpace, Self::IgnoreDups].as_slice(),
349                Self::IgnoreSpace => &[Self::IgnoreSpace],
350                Self::IgnoreDups => &[Self::IgnoreDups],
351                Self::EraseDups => &[Self::EraseDups],
352            })
353            .copied()
354            .collect()
355    }
356}
357
358/// Represents a timestamp for a history item.
359pub type ItemTimestamp = chrono::DateTime<Utc>;
360
361/// Represents an item in the history.
362#[derive(Clone, Default)]
363#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
364pub struct Item {
365    /// The unique identifier of the history item.
366    pub id: ItemId,
367    /// The actual command line.
368    pub command_line: String,
369    /// The timestamp when the command was started.
370    pub timestamp: Option<ItemTimestamp>,
371    /// Whether or not the item is dirty, i.e., has not yet been written to
372    /// backing storage.
373    pub dirty: bool,
374}
375
376impl Item {
377    /// Constructs a new `Item` with the given command line.
378    ///
379    /// # Arguments
380    ///
381    /// * `command_line` - The command line of the item.
382    pub fn new(command_line: impl Into<String>) -> Self {
383        Self {
384            id: 0, // NOTE: ID will be assigned when added to the history.
385            command_line: command_line.into(),
386            timestamp: Some(chrono::Utc::now()),
387            dirty: true,
388        }
389    }
390}
391
392/// Encapsulates query parameters for searching through history.
393#[derive(Default)]
394pub struct Query {
395    /// Whether to search forward or backward
396    pub direction: Direction,
397    /// Optionally, clamp results to items with a timestamp strictly after this.
398    pub not_at_or_before_time: Option<ItemTimestamp>,
399    /// Optionally, clamp results to items with a timestamp strictly before
400    /// this.
401    pub not_at_or_after_time: Option<ItemTimestamp>,
402    /// Optionally, clamp results to items with an ID equal strictly after this.
403    pub not_at_or_before_id: Option<ItemId>,
404    /// Optionally, clamp results to items with an ID equal strictly before
405    /// this.
406    pub not_at_or_after_id: Option<ItemId>,
407    /// Optionally, maximum number of items to retrieve
408    pub max_items: Option<i64>,
409    /// Optionally, a string-based filter on command line.
410    pub command_line_filter: Option<CommandLineFilter>,
411}
412
413impl Query {
414    /// Checks if the query includes the given item.
415    ///
416    /// # Arguments
417    ///
418    /// * `item` - The item to check.
419    pub fn includes(&self, item: &Item) -> bool {
420        // Filter based on not_at_or_before_time.
421        if let Some(not_at_or_before_time) = &self.not_at_or_before_time {
422            if item
423                .timestamp
424                .is_some_and(|ts| ts <= *not_at_or_before_time)
425            {
426                return false;
427            }
428        }
429
430        // Filter based on not_at_or_after_time
431        if let Some(not_at_or_after_time) = &self.not_at_or_after_time {
432            if item.timestamp.is_some_and(|ts| ts >= *not_at_or_after_time) {
433                return false;
434            }
435        }
436
437        // Filter based on not_at_or_before_id
438        if self
439            .not_at_or_before_id
440            .is_some_and(|query_id| item.id <= query_id)
441        {
442            return false;
443        }
444
445        // Filter based on not_at_or_after_id
446        if self
447            .not_at_or_after_id
448            .is_some_and(|query_id| item.id >= query_id)
449        {
450            return false;
451        }
452
453        // Filter based on command_line_filter
454        if let Some(command_line_filter) = &self.command_line_filter {
455            match command_line_filter {
456                CommandLineFilter::Prefix(prefix) => {
457                    if !item.command_line.starts_with(prefix) {
458                        return false;
459                    }
460                }
461                CommandLineFilter::Suffix(suffix) => {
462                    if !item.command_line.ends_with(suffix) {
463                        return false;
464                    }
465                }
466                CommandLineFilter::Contains(contains) => {
467                    if !item.command_line.contains(contains) {
468                        return false;
469                    }
470                }
471                CommandLineFilter::Exact(exact) => {
472                    if item.command_line != *exact {
473                        return false;
474                    }
475                }
476            }
477        }
478
479        true
480    }
481}
482
483/// Represents the direction of a search operation.
484#[derive(Default)]
485pub enum Direction {
486    /// Search forward from the oldest part of history.
487    #[default]
488    Forward,
489    /// Search backward from the youngest part of history.
490    Backward,
491}
492
493/// Filter criteria for command lines.
494pub enum CommandLineFilter {
495    /// The command line must start with this string.
496    Prefix(String),
497    /// The command line must end with this string.
498    Suffix(String),
499    /// The command line must contain this string.
500    Contains(String),
501    /// The command line must match this string exactly.
502    Exact(String),
503}
504
505/// Represents a search operation.
506pub struct Search<'a> {
507    /// The history to search through.
508    history: &'a History,
509    /// The positions of the items left to search: those within the query's ID
510    /// bounds.
511    positions: std::ops::Range<usize>,
512    /// The query to apply.
513    query: Query,
514    /// Count of items returned so far.
515    count: usize,
516}
517
518impl<'a> Search<'a> {
519    /// Constructs a new search against the provided history, querying *all*
520    /// items.
521    ///
522    /// # Arguments
523    ///
524    /// * `history` - The history to search through.
525    pub fn all(history: &'a History) -> Self {
526        Self::new(history, Query::default())
527    }
528
529    /// Constructs a new search against the provided history, using the given
530    /// query.
531    ///
532    /// # Arguments
533    ///
534    /// * `history` - The history to search through.
535    /// * `query` - The query to use.
536    pub fn new(history: &'a History, query: Query) -> Self {
537        // The items within the query's ID bounds are a run of them, since
538        // they're in ID order: search just those positions.
539        let start = query.not_at_or_before_id.map_or(0, |id| {
540            id.checked_add(1)
541                .map_or(history.items.len(), |id| history.position_before(id))
542        });
543        let end = query
544            .not_at_or_after_id
545            .map_or(history.items.len(), |id| history.position_before(id));
546
547        Self {
548            history,
549            positions: start..end.max(start),
550            query,
551            count: 0,
552        }
553    }
554}
555
556impl<'a> Iterator for Search<'a> {
557    type Item = &'a Item;
558
559    fn next(&mut self) -> Option<Self::Item> {
560        // Once we hit the limit, we stop searching.
561        #[expect(clippy::cast_possible_truncation)]
562        #[expect(clippy::cast_sign_loss)]
563        if self
564            .query
565            .max_items
566            .is_some_and(|max_items| self.count >= max_items as usize)
567        {
568            return None;
569        }
570
571        loop {
572            let index = match self.query.direction {
573                Direction::Forward => self.positions.next(),
574                Direction::Backward => self.positions.next_back(),
575            }?;
576            let item = self.history.items.get(index)?;
577            if self.query.includes(item) {
578                self.count += 1;
579                return Some(item);
580            }
581        }
582    }
583}
584
585#[cfg(test)]
586#[allow(clippy::panic_in_result_fn, reason = "assertions in a fallible test")]
587mod tests {
588    use super::*;
589
590    fn history(lines: &[&str]) -> Result<History, error::Error> {
591        let mut history = History::default();
592        for line in lines {
593            history.add(Item::new(*line))?;
594        }
595        Ok(history)
596    }
597
598    fn command_lines<'a>(items: impl Iterator<Item = &'a Item>) -> Vec<&'a str> {
599        items.map(|item| item.command_line.as_str()).collect()
600    }
601
602    /// Removing an item shifts the positions of those after it (as `history -d`
603    /// shows), but no item's ID changes: an ID names its item for good.
604    #[test]
605    fn ids_are_stable_across_removal() -> Result<(), error::Error> {
606        let mut history = history(&["a", "b", "c", "d"])?;
607
608        assert!(history.remove_nth_item(1));
609        assert_eq!(
610            history.get(1).map(|item| item.command_line.as_str()),
611            Some("c")
612        );
613        assert_eq!(
614            history.get_by_id(2)?.map(|item| item.command_line.as_str()),
615            Some("c")
616        );
617
618        history.delete_item_by_id(0)?;
619        assert_eq!(command_lines(history.iter()), ["c", "d"]);
620        assert!(history.get_by_id(0)?.is_none());
621        assert!(history.get_by_id(1)?.is_none());
622        assert_eq!(history.get_by_id(3)?.map(|item| item.id), Some(3));
623
624        // New items still get new IDs.
625        assert_eq!(history.add(Item::new("e"))?, 4);
626        assert_eq!(
627            history.get_by_id(4)?.map(|item| item.command_line.as_str()),
628            Some("e")
629        );
630
631        // Even after clearing history: an old ID never comes to name a new
632        // item.
633        history.clear()?;
634        assert_eq!(history.add(Item::new("f"))?, 5);
635        assert!(history.get_by_id(0)?.is_none());
636
637        Ok(())
638    }
639
640    /// Updating an item keeps its ID, whatever ID the new item carries.
641    #[test]
642    fn update_by_id_keeps_the_id() -> Result<(), error::Error> {
643        let mut history = history(&["a", "b"])?;
644
645        history.update_by_id(
646            1,
647            Item {
648                id: 99,
649                ..Item::new("x")
650            },
651        )?;
652        let updated = history
653            .get_by_id(1)?
654            .map(|item| (item.id, item.command_line.as_str()));
655        assert_eq!(updated, Some((1, "x")));
656        assert!(history.get_by_id(99)?.is_none());
657
658        assert!(history.update_by_id(7, Item::new("y")).is_err());
659
660        Ok(())
661    }
662
663    #[test]
664    fn search_by_direction_bounds_and_prefix() -> Result<(), error::Error> {
665        let history = history(&["echo hi there", "ls", "echo hello world", "cd /tmp"])?;
666        let search = |query| history.search(query).map(command_lines);
667        let prefix = |text: &str| Some(CommandLineFilter::Prefix(text.to_owned()));
668
669        // The newest match, as a hint asks for...
670        let newest = Query {
671            direction: Direction::Backward,
672            max_items: Some(1),
673            command_line_filter: prefix("echo h"),
674            ..Query::default()
675        };
676        assert_eq!(search(newest)?, ["echo hello world"]);
677
678        // ...and older than a given ID, as history navigation asks for.
679        let older = Query {
680            direction: Direction::Backward,
681            not_at_or_after_id: Some(2),
682            command_line_filter: prefix("echo h"),
683            ..Query::default()
684        };
685        assert_eq!(search(older)?, ["echo hi there"]);
686
687        let newer = Query {
688            not_at_or_before_id: Some(0),
689            not_at_or_after_id: Some(3),
690            ..Query::default()
691        };
692        assert_eq!(search(newer)?, ["ls", "echo hello world"]);
693
694        // Newer than a given ID, with no upper bound, as moving down through
695        // history asks.
696        let down = Query {
697            not_at_or_before_id: Some(1),
698            ..Query::default()
699        };
700        assert_eq!(search(down)?, ["echo hello world", "cd /tmp"]);
701
702        // By substring (as Ctrl-R searches) or exact match, or with no room for
703        // any.
704        let substring = Query {
705            direction: Direction::Backward,
706            command_line_filter: Some(CommandLineFilter::Contains("hel".to_owned())),
707            ..Query::default()
708        };
709        assert_eq!(search(substring)?, ["echo hello world"]);
710        let exact = Query {
711            command_line_filter: Some(CommandLineFilter::Exact("ls".to_owned())),
712            ..Query::default()
713        };
714        assert_eq!(search(exact)?, ["ls"]);
715        let no_room = Query {
716            max_items: Some(0),
717            ..Query::default()
718        };
719        assert_eq!(search(no_room)?, Vec::<&str>::new());
720
721        let none = Query {
722            direction: Direction::Backward,
723            command_line_filter: prefix("echo hix"),
724            ..Query::default()
725        };
726        assert_eq!(search(none)?, Vec::<&str>::new());
727
728        Ok(())
729    }
730
731    /// A search finds just what checking each item would, in order, whatever
732    /// its direction, ID bounds, limit, and filter -- including bounds at
733    /// deleted IDs and at the extremes. And an item is found by its ID just
734    /// as a scan would find it.
735    #[test]
736    fn search_and_get_by_id_match_a_scan() -> Result<(), error::Error> {
737        for len in 0..6_i64 {
738            for deleted in 0..(1 << len) {
739                let mut history = History::default();
740                for id in 0..len {
741                    history.add(Item::new(if id % 2 == 0 { "even" } else { "odd" }))?;
742                }
743                for id in (0..len).filter(|id| deleted & (1 << id) != 0) {
744                    history.delete_item_by_id(id)?;
745                }
746                let scan = || (0..history.count()).filter_map(|index| history.get(index));
747
748                let bounds = [None, Some(ItemId::MIN), Some(-1), Some(0), Some(2)]
749                    .into_iter()
750                    .chain([Some(len - 1), Some(len), Some(ItemId::MAX)]);
751                for lower in bounds.clone() {
752                    for upper in bounds.clone() {
753                        for backward in [false, true] {
754                            for max_items in [None, Some(0), Some(1), Some(2), Some(-1)] {
755                                for filter in [None, Some("odd")] {
756                                    let query = || Query {
757                                        direction: if backward {
758                                            Direction::Backward
759                                        } else {
760                                            Direction::Forward
761                                        },
762                                        not_at_or_before_id: lower,
763                                        not_at_or_after_id: upper,
764                                        max_items,
765                                        command_line_filter: filter
766                                            .map(|f| CommandLineFilter::Prefix(f.to_owned())),
767                                        ..Query::default()
768                                    };
769
770                                    let found: Vec<ItemId> =
771                                        history.search(query())?.map(|item| item.id).collect();
772                                    let mut expected: Vec<ItemId> = scan()
773                                        .filter(|item| query().includes(item))
774                                        .map(|item| item.id)
775                                        .collect();
776                                    if backward {
777                                        expected.reverse();
778                                    }
779                                    // A negative limit is no limit.
780                                    if let Some(max) =
781                                        max_items.and_then(|m| usize::try_from(m).ok())
782                                    {
783                                        expected.truncate(max);
784                                    }
785                                    assert_eq!(
786                                        found, expected,
787                                        "len {len}, deleted {deleted:b}, bounds {lower:?}..{upper:?}, backward {backward}, max {max_items:?}, filter {filter:?}"
788                                    );
789                                }
790                            }
791                        }
792                    }
793                }
794
795                for id in -1..=len {
796                    let by_scan = scan().find(|item| item.id == id).map(|item| item.id);
797                    assert_eq!(history.get_by_id(id)?.map(|item| item.id), by_scan);
798                }
799            }
800        }
801
802        Ok(())
803    }
804
805    /// Appending unsaved items writes just those, once; writing everything
806    /// writes it all.
807    #[test]
808    fn flush_writes_unsaved_items_once() -> Result<(), error::Error> {
809        let dir = tempfile::tempdir()?;
810        let path = dir.path().join("history");
811        let read = || std::fs::read_to_string(&path);
812
813        // Imported items are already saved; added ones aren't.
814        let mut history = History::import(&b"old\n"[..])?;
815        history.add(Item::new("new1"))?;
816        history.add(Item::new("new2"))?;
817
818        history.flush(&path, true, true, false)?;
819        assert_eq!(read()?, "new1\nnew2\n");
820        history.flush(&path, true, true, false)?;
821        assert_eq!(read()?, "new1\nnew2\n");
822
823        history.flush(&path, false, false, false)?;
824        assert_eq!(read()?, "old\nnew1\nnew2\n");
825
826        Ok(())
827    }
828
829    /// Each of a query's filters, on its own. ID and time bounds are exclusive,
830    /// and an item with no timestamp isn't excluded by time bounds.
831    #[test]
832    fn query_includes_by_each_filter() {
833        let at = |seconds| ItemTimestamp::from_timestamp(seconds, 0);
834        let item = |id, command_line: &str, timestamp| Item {
835            id,
836            command_line: command_line.to_owned(),
837            timestamp,
838            dirty: false,
839        };
840
841        assert!(Query::default().includes(&item(5, "echo hi", None)));
842
843        let after_id = Query {
844            not_at_or_before_id: Some(2),
845            ..Query::default()
846        };
847        assert!(!after_id.includes(&item(2, "x", None)));
848        assert!(after_id.includes(&item(3, "x", None)));
849        let before_id = Query {
850            not_at_or_after_id: Some(2),
851            ..Query::default()
852        };
853        assert!(!before_id.includes(&item(2, "x", None)));
854        assert!(before_id.includes(&item(1, "x", None)));
855
856        let after_time = Query {
857            not_at_or_before_time: at(100),
858            ..Query::default()
859        };
860        assert!(!after_time.includes(&item(0, "x", at(100))));
861        assert!(after_time.includes(&item(0, "x", at(101))));
862        assert!(after_time.includes(&item(0, "x", None)));
863        let before_time = Query {
864            not_at_or_after_time: at(100),
865            ..Query::default()
866        };
867        assert!(!before_time.includes(&item(0, "x", at(100))));
868        assert!(before_time.includes(&item(0, "x", at(99))));
869        assert!(before_time.includes(&item(0, "x", None)));
870
871        for (filter, matching, other) in [
872            (CommandLineFilter::Prefix("ec".to_owned()), "echo", "sec"),
873            (CommandLineFilter::Suffix("ho".to_owned()), "echo", "hot"),
874            (CommandLineFilter::Contains("ch".to_owned()), "echo", "cd"),
875            (CommandLineFilter::Exact("echo".to_owned()), "echo", "echo "),
876        ] {
877            let query = Query {
878                command_line_filter: Some(filter),
879                ..Query::default()
880            };
881            assert!(query.includes(&item(0, matching, None)), "{matching:?}");
882            assert!(!query.includes(&item(0, other, None)), "{other:?}");
883        }
884    }
885
886    /// An empty history, positions past the end, and IDs no item has.
887    #[test]
888    fn empty_history_and_missing_items() -> Result<(), error::Error> {
889        let mut empty = History::default();
890        assert!(empty.is_empty());
891        assert_eq!(empty.count(), 0);
892        assert!(empty.get(0).is_none());
893        assert!(empty.get_by_id(0)?.is_none());
894        assert!(!empty.remove_nth_item(0));
895        empty.delete_item_by_id(0)?;
896        assert!(empty.update_by_id(0, Item::new("x")).is_err());
897        assert_eq!(empty.iter().count(), 0);
898        assert_eq!(empty.search(Query::default())?.count(), 0);
899
900        let mut history = history(&["a", "b", "c"])?;
901        assert!(!history.is_empty());
902        assert_eq!(history.count(), 3);
903        assert!(history.get(3).is_none());
904        assert!(!history.remove_nth_item(3));
905        history.delete_item_by_id(7)?;
906        assert_eq!(command_lines(history.iter()), ["a", "b", "c"]);
907
908        // Updating an item changes just it, where it is.
909        history.update_by_id(1, Item::new("B"))?;
910        assert_eq!(command_lines(history.iter()), ["a", "B", "c"]);
911        assert_eq!(command_lines(Search::all(&history)), ["a", "B", "c"]);
912
913        Ok(())
914    }
915
916    /// A new item is unsaved and stamped now; adding it gives it the next ID.
917    #[test]
918    fn new_items_are_unsaved_and_stamped() -> Result<(), error::Error> {
919        let before = Utc::now();
920        let item = Item::new("ls");
921        assert!(item.dirty);
922        assert!(item.timestamp.is_some_and(|ts| ts >= before));
923
924        let mut history = history(&["a"])?;
925        assert_eq!(history.add(item)?, 1);
926        assert_eq!(history.get(1).map(|item| item.id), Some(1));
927
928        Ok(())
929    }
930
931    /// Importing reads command lines and the timestamp comments before them
932    /// (the last of several; a comment that isn't one clears it), skips
933    /// unreadable lines, and leaves the items saved and numbered from 0.
934    #[test]
935    fn import_reads_history_files() -> Result<(), error::Error> {
936        let before = Utc::now();
937        let file = b"#1700000000\na\n#1700000005\n#not a time\nb\n#1700000001\n#1700000002\nc\n\xff\xfe\nd\n";
938        let mut history = History::import(&file[..])?;
939
940        assert_eq!(command_lines(history.iter()), ["a", "b", "c", "d"]);
941        let timestamps: Vec<_> = history.iter().map(|item| item.timestamp).collect();
942        assert_eq!(
943            timestamps[0],
944            ItemTimestamp::from_timestamp(1_700_000_000, 0)
945        );
946        assert!(timestamps[1].is_some_and(|ts| ts >= before));
947        assert_eq!(
948            timestamps[2],
949            ItemTimestamp::from_timestamp(1_700_000_002, 0)
950        );
951        assert!(timestamps[3].is_some_and(|ts| ts >= before));
952        assert!(history.iter().all(|item| !item.dirty));
953        assert_eq!(
954            history.iter().map(|item| item.id).collect::<Vec<_>>(),
955            [0, 1, 2, 3]
956        );
957        assert_eq!(history.add(Item::new("e"))?, 4);
958
959        assert!(History::import(&b""[..])?.is_empty());
960
961        Ok(())
962    }
963
964    /// Flushing appends to what's already in the file, with the timestamps of
965    /// items that have them if asked; importing that reads them back.
966    #[test]
967    fn flush_and_import_round_trip_with_timestamps() -> Result<(), error::Error> {
968        let dir = tempfile::tempdir()?;
969        let path = dir.path().join("history");
970        std::fs::write(&path, "existing\n")?;
971
972        let mut history = History::default();
973        history.add(Item {
974            timestamp: ItemTimestamp::from_timestamp(1_700_000_000, 0),
975            ..Item::new("a")
976        })?;
977        history.add(Item {
978            timestamp: None,
979            ..Item::new("b")
980        })?;
981        history.flush(&path, true, true, true)?;
982        assert_eq!(
983            std::fs::read_to_string(&path)?,
984            "existing\n#1700000000\na\nb\n"
985        );
986
987        let reread = History::import(std::fs::File::open(&path)?)?;
988        assert_eq!(command_lines(reread.iter()), ["existing", "a", "b"]);
989        assert_eq!(
990            reread.get(1).and_then(|item| item.timestamp),
991            ItemTimestamp::from_timestamp(1_700_000_000, 0)
992        );
993
994        Ok(())
995    }
996
997    #[test]
998    fn last_and_retain() -> Result<(), error::Error> {
999        assert!(History::default().last().is_none());
1000
1001        let mut history = history(&["a", "x", "b", "x", "c", "x"])?;
1002        assert_eq!(
1003            history.last().map(|item| item.command_line.as_str()),
1004            Some("x")
1005        );
1006
1007        // Keeping everything changes nothing.
1008        history.retain(|_| true);
1009        assert_eq!(
1010            command_lines(history.iter()),
1011            ["a", "x", "b", "x", "c", "x"]
1012        );
1013
1014        // Removing some keeps the rest in order, with their IDs.
1015        history.retain(|item| item.command_line != "x");
1016        assert_eq!(command_lines(history.iter()), ["a", "b", "c"]);
1017        assert_eq!(
1018            history.iter().map(|item| item.id).collect::<Vec<_>>(),
1019            [0, 2, 4]
1020        );
1021        assert_eq!(
1022            history.get_by_id(4)?.map(|item| item.command_line.as_str()),
1023            Some("c")
1024        );
1025
1026        history.retain(|item| item.command_line != "a");
1027        assert_eq!(command_lines(history.iter()), ["b", "c"]);
1028        history.retain(|_| false);
1029        assert!(history.is_empty());
1030
1031        Ok(())
1032    }
1033
1034    /// `HISTCONTROL` parses as bash reads it: `ignoreboth` is two values, and
1035    /// unknown values, empty ones, and other cases are ignored.
1036    #[test]
1037    fn history_control_parses_like_bash() {
1038        use HistoryControl::{EraseDups, IgnoreDups, IgnoreSpace};
1039        let parse = |value| {
1040            HistoryControl::parse_list(value)
1041                .into_iter()
1042                .collect::<Vec<_>>()
1043        };
1044
1045        assert_eq!(parse("ignoredups"), [IgnoreDups]);
1046        assert_eq!(parse("ignorespace"), [IgnoreSpace]);
1047        assert_eq!(parse("erasedups"), [EraseDups]);
1048        assert_eq!(parse("ignoreboth"), [IgnoreSpace, IgnoreDups]);
1049        assert_eq!(parse("erasedups:ignorespace"), [IgnoreSpace, EraseDups]);
1050        assert_eq!(parse("bogus::ignoredups:"), [IgnoreDups]);
1051        assert_eq!(parse("IGNOREDUPS"), []);
1052        assert_eq!(parse(""), []);
1053    }
1054
1055    /// Searches and iterators over history can be held across an await in a
1056    /// spawned task.
1057    #[test]
1058    fn searches_are_send_and_sync() -> Result<(), error::Error> {
1059        const fn assert_send_sync<T: Send + Sync>(_: &T) {}
1060
1061        let history = history(&["a"])?;
1062        assert_send_sync(&history.iter());
1063        assert_send_sync(&history.search(Query::default())?);
1064
1065        Ok(())
1066    }
1067
1068    /// Like bash, an entry the file gives no timestamp gets the time it's read.
1069    #[test]
1070    fn import_stamps_entries_without_timestamps() -> Result<(), error::Error> {
1071        let before = Utc::now();
1072        let history = History::import(&b"#1700000000\nls\necho hi\n"[..])?;
1073
1074        let timestamps: Vec<_> = history.iter().map(|item| item.timestamp).collect();
1075        assert_eq!(
1076            timestamps[0],
1077            ItemTimestamp::from_timestamp(1_700_000_000, 0)
1078        );
1079        assert!(timestamps[1].is_some_and(|ts| ts >= before));
1080
1081        Ok(())
1082    }
1083}