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