Skip to main content

shrub_core/shell/
history.rs

1//! History management for shells.
2
3use std::collections::BTreeSet;
4use std::path::PathBuf;
5
6use crate::history::HistoryControl;
7use crate::{error, openfiles};
8
9impl<SE: crate::extensions::ShellExtensions> crate::Shell<SE> {
10    pub(super) fn load_history(&self) -> Result<Option<crate::history::History>, error::Error> {
11        const MAX_FILE_SIZE_FOR_HISTORY_IMPORT: u64 = 1024 * 1024 * 1024; // 1 GiB
12
13        let Some(history_path) = self.history_file_path() else {
14            return Ok(None);
15        };
16
17        let mut options = std::fs::File::options();
18        options.read(true);
19
20        let mut history_file =
21            self.open_file(&options, history_path, &self.default_exec_params())?;
22
23        // Check on the file's size.
24        if let openfiles::OpenFile::File(file) = &mut history_file {
25            let file_metadata = file.metadata()?;
26            let file_size = file_metadata.len();
27
28            // If the file is empty, no reason to try reading it. Note that this
29            // will also end up excluding non-regular files that
30            // report a 0 file size but appear to have contents when
31            // read.
32            if file_size == 0 {
33                return Ok(None);
34            }
35
36            // Bail if the file is unrealistically large. For now we just refuse
37            // to import it.
38            if file_size > MAX_FILE_SIZE_FOR_HISTORY_IMPORT {
39                return Err(error::ErrorKind::HistoryFileTooLargeToImport.into());
40            }
41        }
42
43        let history = crate::history::History::import(history_file)?;
44        Ok(Some(history))
45    }
46
47    /// Returns the path to the history file used by the shell, if one is set.
48    pub fn history_file_path(&self) -> Option<PathBuf> {
49        self.env_str("HISTFILE")
50            .filter(|s| !s.is_empty())
51            .map(|s| PathBuf::from(s.into_owned()))
52    }
53
54    /// Returns the path to the history file used by the shell, if one is set.
55    pub fn history_time_format(&self) -> Option<String> {
56        self.env_str("HISTTIMEFORMAT").map(|s| s.into_owned())
57    }
58
59    /// Saves history back to any backing storage.
60    ///
61    /// # Errors
62    ///
63    /// Returns [`crate::error::ErrorKind::IoError`] if the history file cannot
64    /// be opened or written.
65    pub fn save_history(&mut self) -> Result<(), error::Error> {
66        if let Some(history_file_path) = self.history_file_path()
67            && let Some(history) = &mut self.history
68        {
69            // See if there's *any* time format configured. That triggers
70            // writing out timestamps.
71            let write_timestamps = self.env.is_set("HISTTIMEFORMAT");
72
73            // TODO(history): Observe options.append_to_history_file
74            history.flush(
75                history_file_path,
76                true, /* append? */
77                true, /* unsaved items only? */
78                write_timestamps,
79            )?;
80        }
81
82        Ok(())
83    }
84
85    /// Adds a command to history. Like bash, an interactive shell leaves it out
86    /// or makes room for it as the colon-separated values of `HISTCONTROL`
87    /// say:
88    ///
89    /// - `ignorespace`: leave it out if it starts with a space.
90    /// - `ignoredups`: leave it out if it's the same as the last entry.
91    /// - `ignoreboth`: both of those.
92    /// - `erasedups`: first remove any earlier entries the same as it.
93    ///
94    /// # Errors
95    ///
96    /// This currently always succeeds; the `Result` leaves room for history
97    /// backends that can fail.
98    pub fn add_to_history(&mut self, command: &str) -> Result<(), error::Error> {
99        let controls = if self.options.interactive {
100            self.env_str("HISTCONTROL")
101                .map(|value| HistoryControl::parse_list(&value))
102                .unwrap_or_default()
103        } else {
104            BTreeSet::new()
105        };
106
107        let Some(history) = &mut self.history else {
108            return Ok(());
109        };
110
111        if controls.contains(&HistoryControl::IgnoreSpace) && command.starts_with(' ') {
112            return Ok(());
113        }
114
115        // Like bash, save the command as typed, surrounding whitespace and all,
116        // but leave out blank ones.
117        if command.trim().is_empty() {
118            return Ok(());
119        }
120
121        if controls.contains(&HistoryControl::IgnoreDups)
122            && history
123                .last()
124                .is_some_and(|last| last.command_line == command)
125        {
126            return Ok(());
127        }
128        if controls.contains(&HistoryControl::EraseDups) {
129            history.retain(|item| item.command_line != command);
130        }
131
132        history.add(crate::history::Item {
133            id: 0,
134            command_line: command.to_owned(),
135            timestamp: Some(chrono::Utc::now()),
136            dirty: true,
137        })?;
138
139        Ok(())
140    }
141}