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> {
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 Err(err) if err.kind() == std::io::ErrorKind::InvalidData => {
53 tracing::warn!("unreadable history line; {err}");
54 continue;
55 }
56 Err(err) => {
59 return Err(err.into());
60 }
61 };
62
63 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 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 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 self.items.set_mut(index, Item { id, ..item });
110 Ok(())
111 }
112
113 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 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 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 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 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 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 pub fn clear(&mut self) -> Result<(), error::Error> {
193 self.items = rpds::VectorSync::new_sync();
194 Ok(())
195 }
196
197 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 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 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 pub fn search(&self, query: Query) -> Result<impl Iterator<Item = &self::Item>, error::Error> {
262 Ok(Search::new(self, query))
263 }
264
265 pub fn is_empty(&self) -> bool {
267 self.items.is_empty()
268 }
269
270 pub fn iter(&self) -> impl Iterator<Item = &self::Item> {
272 self.items.iter()
273 }
274
275 pub fn get(&self, index: usize) -> Option<&Item> {
283 self.items.get(index)
284 }
285
286 pub fn last(&self) -> Option<&Item> {
288 self.items.last()
289 }
290
291 pub fn count(&self) -> usize {
293 self.items.len()
294 }
295
296 fn position_before(&self, id: ItemId) -> usize {
299 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 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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, strum_macros::EnumString)]
327#[strum(serialize_all = "lowercase")]
328pub(crate) enum HistoryControl {
329 IgnoreSpace,
331 IgnoreDups,
333 IgnoreBoth,
335 EraseDups,
337}
338
339impl HistoryControl {
340 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
358pub type ItemTimestamp = chrono::DateTime<Utc>;
360
361#[derive(Clone, Default)]
363#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
364pub struct Item {
365 pub id: ItemId,
367 pub command_line: String,
369 pub timestamp: Option<ItemTimestamp>,
371 pub dirty: bool,
374}
375
376impl Item {
377 pub fn new(command_line: impl Into<String>) -> Self {
383 Self {
384 id: 0, command_line: command_line.into(),
386 timestamp: Some(chrono::Utc::now()),
387 dirty: true,
388 }
389 }
390}
391
392#[derive(Default)]
394pub struct Query {
395 pub direction: Direction,
397 pub not_at_or_before_time: Option<ItemTimestamp>,
399 pub not_at_or_after_time: Option<ItemTimestamp>,
402 pub not_at_or_before_id: Option<ItemId>,
404 pub not_at_or_after_id: Option<ItemId>,
407 pub max_items: Option<i64>,
409 pub command_line_filter: Option<CommandLineFilter>,
411}
412
413impl Query {
414 pub fn includes(&self, item: &Item) -> bool {
420 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 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 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 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 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#[derive(Default)]
485pub enum Direction {
486 #[default]
488 Forward,
489 Backward,
491}
492
493pub enum CommandLineFilter {
495 Prefix(String),
497 Suffix(String),
499 Contains(String),
501 Exact(String),
503}
504
505pub struct Search<'a> {
507 history: &'a History,
509 positions: std::ops::Range<usize>,
512 query: Query,
514 count: usize,
516}
517
518impl<'a> Search<'a> {
519 pub fn all(history: &'a History) -> Self {
526 Self::new(history, Query::default())
527 }
528
529 pub fn new(history: &'a History, query: Query) -> Self {
537 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 #[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 #[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 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 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 #[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 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 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 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 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 #[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 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 #[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 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 #[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 #[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 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 #[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 #[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 #[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 history.retain(|_| true);
1009 assert_eq!(
1010 command_lines(history.iter()),
1011 ["a", "x", "b", "x", "c", "x"]
1012 );
1013
1014 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 #[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 #[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 #[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}