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    pub fn save_history(&mut self) -> Result<(), error::Error> {
61        if let Some(history_file_path) = self.history_file_path()
62            && let Some(history) = &mut self.history
63        {
64            // See if there's *any* time format configured. That triggers
65            // writing out timestamps.
66            let write_timestamps = self.env.is_set("HISTTIMEFORMAT");
67
68            // TODO(history): Observe options.append_to_history_file
69            history.flush(
70                history_file_path,
71                true, /* append? */
72                true, /* unsaved items only? */
73                write_timestamps,
74            )?;
75        }
76
77        Ok(())
78    }
79
80    /// Adds a command to history. Like bash, an interactive shell leaves it out
81    /// or makes room for it as the colon-separated values of `HISTCONTROL`
82    /// say:
83    ///
84    /// - `ignorespace`: leave it out if it starts with a space.
85    /// - `ignoredups`: leave it out if it's the same as the last entry.
86    /// - `ignoreboth`: both of those.
87    /// - `erasedups`: first remove any earlier entries the same as it.
88    pub fn add_to_history(&mut self, command: &str) -> Result<(), error::Error> {
89        let controls = if self.options.interactive {
90            self.env_str("HISTCONTROL")
91                .map(|value| HistoryControl::parse_list(&value))
92                .unwrap_or_default()
93        } else {
94            BTreeSet::new()
95        };
96
97        let Some(history) = &mut self.history else {
98            return Ok(());
99        };
100
101        if controls.contains(&HistoryControl::IgnoreSpace) && command.starts_with(' ') {
102            return Ok(());
103        }
104
105        // Like bash, save the command as typed, surrounding whitespace and all,
106        // but leave out blank ones.
107        if command.trim().is_empty() {
108            return Ok(());
109        }
110
111        if controls.contains(&HistoryControl::IgnoreDups)
112            && history
113                .last()
114                .is_some_and(|last| last.command_line == command)
115        {
116            return Ok(());
117        }
118        if controls.contains(&HistoryControl::EraseDups) {
119            history.retain(|item| item.command_line != command);
120        }
121
122        history.add(crate::history::Item {
123            id: 0,
124            command_line: command.to_owned(),
125            timestamp: Some(chrono::Utc::now()),
126            dirty: true,
127        })?;
128
129        Ok(())
130    }
131}