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