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