1use std::collections::BTreeSet;
4use std::io::{BufRead, Read, Write};
5use std::path::Path;
6
7use chrono::Utc;
8
9use crate::error;
10
11type ItemId = i64;
13
14#[derive(Clone, Default)]
18#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
19pub struct History {
20 items: rpds::VectorSync<Item>,
24 next_id: ItemId,
25}
26
27impl History {
28 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 Err(err) if err.kind() == std::io::ErrorKind::InvalidData => {
58 tracing::warn!("unreadable history line; {err}");
59 continue;
60 }
61 Err(err) => {
64 return Err(err.into());
65 }
66 };
67
68 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 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 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 self.items.set_mut(index, Item { id, ..item });
124 Ok(())
125 }
126
127 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 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 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 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 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 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 pub fn clear(&mut self) -> Result<(), error::Error> {
219 self.items = rpds::VectorSync::new_sync();
220 Ok(())
221 }
222
223 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 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 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 pub fn search(&self, query: Query) -> Result<impl Iterator<Item = &self::Item>, error::Error> {
297 Ok(Search::new(self, query))
298 }
299
300 pub fn is_empty(&self) -> bool {
302 self.items.is_empty()
303 }
304
305 pub fn iter(&self) -> impl Iterator<Item = &self::Item> {
307 self.items.iter()
308 }
309
310 pub fn get(&self, index: usize) -> Option<&Item> {
318 self.items.get(index)
319 }
320
321 pub fn last(&self) -> Option<&Item> {
323 self.items.last()
324 }
325
326 pub fn count(&self) -> usize {
328 self.items.len()
329 }
330
331 fn position_before(&self, id: ItemId) -> usize {
334 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 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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, strum_macros::EnumString)]
362#[strum(serialize_all = "lowercase")]
363pub(crate) enum HistoryControl {
364 IgnoreSpace,
366 IgnoreDups,
368 IgnoreBoth,
370 EraseDups,
372}
373
374impl HistoryControl {
375 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
393pub type ItemTimestamp = chrono::DateTime<Utc>;
395
396#[derive(Clone, Default)]
398#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
399pub struct Item {
400 pub id: ItemId,
402 pub command_line: String,
404 pub timestamp: Option<ItemTimestamp>,
406 pub dirty: bool,
409}
410
411impl Item {
412 pub fn new(command_line: impl Into<String>) -> Self {
418 Self {
419 id: 0, command_line: command_line.into(),
421 timestamp: Some(chrono::Utc::now()),
422 dirty: true,
423 }
424 }
425}
426
427#[derive(Default)]
429pub struct Query {
430 pub direction: Direction,
432 pub not_at_or_before_time: Option<ItemTimestamp>,
434 pub not_at_or_after_time: Option<ItemTimestamp>,
437 pub not_at_or_before_id: Option<ItemId>,
439 pub not_at_or_after_id: Option<ItemId>,
442 pub max_items: Option<i64>,
444 pub command_line_filter: Option<CommandLineFilter>,
446}
447
448impl Query {
449 pub fn includes(&self, item: &Item) -> bool {
455 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 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 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 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 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#[derive(Default)]
520pub enum Direction {
521 #[default]
523 Forward,
524 Backward,
526}
527
528pub enum CommandLineFilter {
530 Prefix(String),
532 Suffix(String),
534 Contains(String),
536 Exact(String),
538}
539
540pub struct Search<'a> {
542 history: &'a History,
544 positions: std::ops::Range<usize>,
547 query: Query,
549 count: usize,
551}
552
553impl<'a> Search<'a> {
554 pub fn all(history: &'a History) -> Self {
561 Self::new(history, Query::default())
562 }
563
564 pub fn new(history: &'a History, query: Query) -> Self {
572 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 #[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 #[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 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 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 #[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 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 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 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 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 #[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 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 #[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 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 #[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 #[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 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 #[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 #[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 #[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 history.retain(|_| true);
1044 assert_eq!(
1045 command_lines(history.iter()),
1046 ["a", "x", "b", "x", "c", "x"]
1047 );
1048
1049 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 #[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 #[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 #[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}