Skip to main content

nu_protocol/config/
history.rs

1use super::{config_update_string_enum, prelude::*};
2use crate::{self as nu_protocol, ConfigWarning};
3use std::path::PathBuf;
4
5#[derive(Clone, Copy, Debug, IntoValue, PartialEq, Eq, Serialize, Deserialize)]
6pub enum HistoryFileFormat {
7    /// Store history as an SQLite database with additional context
8    Sqlite,
9    /// store history as a plain text file where every line is one command (without any context such as timestamps)
10    Plaintext,
11}
12
13impl HistoryFileFormat {
14    pub fn default_file_name(self) -> std::path::PathBuf {
15        match self {
16            HistoryFileFormat::Plaintext => "history.txt",
17            HistoryFileFormat::Sqlite => "history.sqlite3",
18        }
19        .into()
20    }
21}
22
23impl FromStr for HistoryFileFormat {
24    type Err = &'static str;
25
26    fn from_str(s: &str) -> Result<Self, Self::Err> {
27        match s.to_ascii_lowercase().as_str() {
28            "sqlite" => Ok(Self::Sqlite),
29            "plaintext" => Ok(Self::Plaintext),
30            #[cfg(feature = "sqlite")]
31            _ => Err("'sqlite' or 'plaintext'"),
32            #[cfg(not(feature = "sqlite"))]
33            _ => Err("'plaintext'"),
34        }
35    }
36}
37
38impl UpdateFromValue for HistoryFileFormat {
39    fn update(&mut self, value: &Value, path: &mut ConfigPath, errors: &mut ConfigErrors) {
40        config_update_string_enum(self, value, path, errors);
41
42        #[cfg(not(feature = "sqlite"))]
43        if *self == HistoryFileFormat::Sqlite {
44            *self = HistoryFileFormat::Plaintext;
45            errors.warn(ConfigWarning::IncompatibleOptions {
46                label: "SQLite-based history file only supported with the `sqlite` feature, falling back to plain text history", 
47                span: value.span(),
48                help: "Compile Nushell with `sqlite` feature enabled",
49            });
50        }
51    }
52}
53
54#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
55pub enum HistoryPath {
56    Default,
57    Custom(PathBuf),
58    Disabled,
59}
60
61#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
62pub struct HistoryConfig {
63    pub max_size: i64,
64    pub sync_on_enter: bool,
65    pub file_format: HistoryFileFormat,
66    pub isolation: bool,
67    pub path: HistoryPath,
68    pub ignore_space_prefixed: bool,
69}
70
71impl IntoValue for HistoryPath {
72    fn into_value(self, span: Span) -> Value {
73        match self {
74            HistoryPath::Default => Value::string("", span),
75            HistoryPath::Disabled => Value::nothing(span),
76            HistoryPath::Custom(path) => Value::string(path.display().to_string(), span),
77        }
78    }
79}
80
81impl IntoValue for HistoryConfig {
82    fn into_value(self, span: Span) -> Value {
83        Value::record(
84            record! {
85                "max_size" => self.max_size.into_value(span),
86                "sync_on_enter" => self.sync_on_enter.into_value(span),
87                "file_format" => self.file_format.into_value(span),
88                "isolation" => self.isolation.into_value(span),
89                "path" => self.path.into_value(span),
90                "ignore_space_prefixed" => self.ignore_space_prefixed.into_value(span),
91            },
92            span,
93        )
94    }
95}
96
97impl HistoryConfig {
98    pub fn file_path(&self) -> Option<PathBuf> {
99        let path = match &self.path {
100            HistoryPath::Custom(path) => Some(path.clone()),
101            HistoryPath::Disabled => None,
102            HistoryPath::Default => nu_path::nu_config_dir().map(|mut history_path| {
103                history_path.push(self.file_format.default_file_name());
104                history_path.into()
105            }),
106        }?;
107
108        if path.is_dir() {
109            return Some(path.join(self.file_format.default_file_name()));
110        }
111
112        Some(path)
113    }
114}
115
116impl Default for HistoryConfig {
117    fn default() -> Self {
118        Self {
119            max_size: 100_000,
120            sync_on_enter: true,
121            file_format: HistoryFileFormat::Plaintext,
122            isolation: false,
123            path: HistoryPath::Default,
124            ignore_space_prefixed: true,
125        }
126    }
127}
128
129impl UpdateFromValue for HistoryConfig {
130    fn update<'a>(
131        &mut self,
132        value: &'a Value,
133        path: &mut ConfigPath<'a>,
134        errors: &mut ConfigErrors,
135    ) {
136        let Value::Record { val: record, .. } = value else {
137            errors.type_mismatch(path, Type::record(), value);
138            return;
139        };
140
141        // might not be correct if file format was changed away from sqlite rather than isolation,
142        // but this is an edge case and the span of the relevant value here should be close enough
143        let mut isolation_span = value.span();
144
145        for (col, val) in record.iter() {
146            let path = &mut path.push(col);
147            match col.as_str() {
148                "isolation" => {
149                    isolation_span = val.span();
150                    let prev = self.isolation;
151                    self.isolation.update(val, path, errors);
152                    if errors.history_locked_after_startup()
153                        && self.isolation != errors.config().history.isolation
154                    {
155                        self.isolation = prev;
156                        errors.locked_after_startup(path, val.span());
157                    }
158                }
159                "sync_on_enter" => self.sync_on_enter.update(val, path, errors),
160                "max_size" => {
161                    let prev = self.max_size;
162                    self.max_size.update(val, path, errors);
163                    if errors.history_locked_after_startup()
164                        && self.max_size != errors.config().history.max_size
165                    {
166                        self.max_size = prev;
167                        errors.locked_after_startup(path, val.span());
168                    }
169                }
170                "file_format" => {
171                    let prev = self.file_format;
172                    self.file_format.update(val, path, errors);
173                    if errors.history_locked_after_startup()
174                        && self.file_format != errors.config().history.file_format
175                    {
176                        self.file_format = prev;
177                        errors.locked_after_startup(path, val.span());
178                    }
179                }
180                "path" => match val {
181                    Value::String { val: s, .. } => {
182                        let new_path = if s.is_empty() {
183                            HistoryPath::Default
184                        } else {
185                            HistoryPath::Custom(PathBuf::from(s))
186                        };
187
188                        if errors.history_locked_after_startup()
189                            && new_path != errors.config().history.path
190                        {
191                            errors.locked_after_startup(path, val.span());
192                            continue;
193                        }
194
195                        self.path = new_path;
196                    }
197                    Value::Nothing { .. } => {
198                        if errors.history_locked_after_startup()
199                            && errors.config().history.path != HistoryPath::Disabled
200                        {
201                            errors.locked_after_startup(path, val.span());
202                            continue;
203                        }
204
205                        self.path = HistoryPath::Disabled;
206                    }
207                    _ => {
208                        errors.type_mismatch(path, Type::custom("string or nothing"), val);
209                    }
210                },
211                "ignore_space_prefixed" => self.ignore_space_prefixed.update(val, path, errors),
212                _ => errors.unknown_option(path, val),
213            }
214        }
215
216        // Listing all formats separately in case additional ones are added
217        match (self.isolation, self.file_format) {
218            (true, HistoryFileFormat::Plaintext) => {
219                errors.warn(ConfigWarning::IncompatibleOptions {
220                    label: "history isolation only compatible with SQLite format",
221                    span: isolation_span,
222                    help: r#"disable history isolation, or set $env.config.history.file_format = "sqlite""#,
223                });
224            }
225            (true, HistoryFileFormat::Sqlite) => (),
226            (false, _) => (),
227        }
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::Config;
235
236    fn config_with_history_path(path: HistoryPath) -> Config {
237        let mut config = Config::default();
238        config.history.path = path;
239        config
240    }
241
242    #[test]
243    fn lock_blocks_changing_to_custom_path() {
244        let old = config_with_history_path(HistoryPath::Default);
245        let mut new = old.clone();
246        let value = Value::test_record(record! {
247            "history" => Value::test_record(record! {
248                "path" => Value::test_string("/tmp/locked.txt"),
249            }),
250        });
251
252        let result = new.update_from_value_with_options(&old, &value, true);
253
254        let err = result.expect_err("should fail when locked");
255        let msg = format!("{err:?}");
256        assert!(
257            msg.contains("LockedAfterStartup"),
258            "expected LockedAfterStartup error, got: {msg}",
259        );
260        assert_eq!(new.history.path, HistoryPath::Default);
261    }
262
263    #[test]
264    fn lock_blocks_disabling_history_at_runtime() {
265        let old = config_with_history_path(HistoryPath::Custom("/tmp/h.txt".into()));
266        let mut new = old.clone();
267        let value = Value::test_record(record! {
268            "history" => Value::test_record(record! {
269                "path" => Value::nothing(Span::test_data()),
270            }),
271        });
272
273        let result = new.update_from_value_with_options(&old, &value, true);
274
275        let err = result.expect_err("should fail when locked");
276        let msg = format!("{err:?}");
277        assert!(
278            msg.contains("LockedAfterStartup"),
279            "expected LockedAfterStartup error, got: {msg}",
280        );
281        assert_eq!(new.history.path, HistoryPath::Custom("/tmp/h.txt".into()));
282    }
283
284    #[test]
285    fn lock_allows_setting_same_value() {
286        let old = config_with_history_path(HistoryPath::Custom("/tmp/h.txt".into()));
287        let mut new = old.clone();
288        let value = Value::test_record(record! {
289            "history" => Value::test_record(record! {
290                "path" => Value::test_string("/tmp/h.txt"),
291            }),
292        });
293
294        let result = new.update_from_value_with_options(&old, &value, true);
295
296        assert!(
297            result.is_ok(),
298            "no-op assignment should succeed: {result:?}"
299        );
300        assert_eq!(new.history.path, HistoryPath::Custom("/tmp/h.txt".into()));
301    }
302
303    #[test]
304    fn lock_allows_setting_default_when_already_default() {
305        let old = config_with_history_path(HistoryPath::Default);
306        let mut new = old.clone();
307        let value = Value::test_record(record! {
308            "history" => Value::test_record(record! {
309                "path" => Value::test_string(""),
310            }),
311        });
312
313        let result = new.update_from_value_with_options(&old, &value, true);
314
315        assert!(
316            result.is_ok(),
317            "no-op assignment should succeed: {result:?}"
318        );
319        assert_eq!(new.history.path, HistoryPath::Default);
320    }
321
322    #[test]
323    fn unlocked_update_changes_path() {
324        let old = config_with_history_path(HistoryPath::Default);
325        let mut new = old.clone();
326        let value = Value::test_record(record! {
327            "history" => Value::test_record(record! {
328                "path" => Value::test_string("/tmp/unlocked.txt"),
329            }),
330        });
331
332        let result = new.update_from_value_with_options(&old, &value, false);
333
334        assert!(result.is_ok(), "unlocked update should succeed: {result:?}");
335        assert_eq!(
336            new.history.path,
337            HistoryPath::Custom("/tmp/unlocked.txt".into())
338        );
339    }
340
341    #[test]
342    fn lock_blocks_changing_max_size() {
343        let old = Config::default();
344        let original_max_size = old.history.max_size;
345        let mut new = old.clone();
346        let value = Value::test_record(record! {
347            "history" => Value::test_record(record! {
348                "max_size" => Value::test_int(original_max_size + 1),
349            }),
350        });
351
352        let result = new.update_from_value_with_options(&old, &value, true);
353
354        let err = result.expect_err("should fail when locked");
355        let msg = format!("{err:?}");
356        assert!(
357            msg.contains("LockedAfterStartup"),
358            "expected LockedAfterStartup error, got: {msg}",
359        );
360        assert_eq!(new.history.max_size, original_max_size);
361    }
362
363    #[test]
364    fn lock_allows_setting_same_max_size() {
365        let old = Config::default();
366        let original_max_size = old.history.max_size;
367        let mut new = old.clone();
368        let value = Value::test_record(record! {
369            "history" => Value::test_record(record! {
370                "max_size" => Value::test_int(original_max_size),
371            }),
372        });
373
374        let result = new.update_from_value_with_options(&old, &value, true);
375
376        assert!(
377            result.is_ok(),
378            "no-op assignment should succeed: {result:?}"
379        );
380        assert_eq!(new.history.max_size, original_max_size);
381    }
382
383    #[cfg(feature = "sqlite")]
384    #[test]
385    fn lock_blocks_changing_file_format() {
386        let old = Config::default();
387        let original_file_format = old.history.file_format;
388        let (_other_format, other_format_str) = match original_file_format {
389            HistoryFileFormat::Plaintext => (HistoryFileFormat::Sqlite, "sqlite"),
390            HistoryFileFormat::Sqlite => (HistoryFileFormat::Plaintext, "plaintext"),
391        };
392        let mut new = old.clone();
393        let value = Value::test_record(record! {
394            "history" => Value::test_record(record! {
395                "file_format" => Value::test_string(other_format_str),
396            }),
397        });
398
399        let result = new.update_from_value_with_options(&old, &value, true);
400
401        let err = result.expect_err("should fail when locked");
402        let msg = format!("{err:?}");
403        assert!(
404            msg.contains("LockedAfterStartup"),
405            "expected LockedAfterStartup error, got: {msg}",
406        );
407        assert_eq!(new.history.file_format, original_file_format);
408    }
409
410    #[test]
411    fn lock_blocks_changing_isolation() {
412        let old = Config::default();
413        let original_isolation = old.history.isolation;
414        let mut new = old.clone();
415        let value = Value::test_record(record! {
416            "history" => Value::test_record(record! {
417                "isolation" => Value::test_bool(!original_isolation),
418            }),
419        });
420
421        let result = new.update_from_value_with_options(&old, &value, true);
422
423        let err = result.expect_err("should fail when locked");
424        let msg = format!("{err:?}");
425        assert!(
426            msg.contains("LockedAfterStartup"),
427            "expected LockedAfterStartup error, got: {msg}",
428        );
429        assert_eq!(new.history.isolation, original_isolation);
430    }
431
432    #[cfg(feature = "sqlite")]
433    #[test]
434    fn unlocked_update_changes_max_size_file_format_isolation() {
435        let old = Config::default();
436        let mut new = old.clone();
437        let new_max_size = old.history.max_size + 1;
438        let (new_file_format, new_file_format_str) = match old.history.file_format {
439            HistoryFileFormat::Plaintext => (HistoryFileFormat::Sqlite, "sqlite"),
440            HistoryFileFormat::Sqlite => (HistoryFileFormat::Plaintext, "plaintext"),
441        };
442        let new_isolation = !old.history.isolation;
443        let value = Value::test_record(record! {
444            "history" => Value::test_record(record! {
445                "max_size" => Value::test_int(new_max_size),
446                "file_format" => Value::test_string(new_file_format_str),
447                "isolation" => Value::test_bool(new_isolation),
448            }),
449        });
450
451        let result = new.update_from_value_with_options(&old, &value, false);
452
453        assert!(result.is_ok(), "unlocked update should succeed: {result:?}");
454        assert_eq!(new.history.max_size, new_max_size);
455        assert_eq!(new.history.file_format, new_file_format);
456        assert_eq!(new.history.isolation, new_isolation);
457    }
458}