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::{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    /// Resolve the history file path.
99    ///
100    /// For [`HistoryPath::Default`], joins the history file name under
101    /// `config_home`. Callers **must** pass the session's already-resolved
102    /// config directory (`EngineState.config_dirs.config_home`) so that CLI
103    /// overrides such as `--config-home` are honored.
104    ///
105    /// Do **not** re-resolve config location from the process environment here —
106    /// that would break session isolation.
107    ///
108    /// # Arguments
109    ///
110    /// * `config_home` — resolved nushell config directory for this session.
111    pub fn file_path(&self, config_home: &Path) -> Option<PathBuf> {
112        let path = match &self.path {
113            HistoryPath::Custom(path) => Some(path.clone()),
114            HistoryPath::Disabled => None,
115            HistoryPath::Default => {
116                let mut history_path = config_home.to_path_buf();
117                history_path.push(self.file_format.default_file_name());
118                Some(history_path)
119            }
120        }?;
121
122        if path.is_dir() {
123            return Some(path.join(self.file_format.default_file_name()));
124        }
125
126        Some(path)
127    }
128}
129
130impl Default for HistoryConfig {
131    fn default() -> Self {
132        Self {
133            max_size: 100_000,
134            sync_on_enter: true,
135            file_format: HistoryFileFormat::Plaintext,
136            isolation: false,
137            path: HistoryPath::Default,
138            ignore_space_prefixed: true,
139        }
140    }
141}
142
143impl UpdateFromValue for HistoryConfig {
144    fn update<'a>(
145        &mut self,
146        value: &'a Value,
147        path: &mut ConfigPath<'a>,
148        errors: &mut ConfigErrors,
149    ) {
150        let Value::Record { val: record, .. } = value else {
151            errors.type_mismatch(path, Type::record(), value);
152            return;
153        };
154
155        // might not be correct if file format was changed away from sqlite rather than isolation,
156        // but this is an edge case and the span of the relevant value here should be close enough
157        let mut isolation_span = value.span();
158
159        for (col, val) in record.iter() {
160            let path = &mut path.push(col);
161            match col.as_str() {
162                "isolation" => {
163                    isolation_span = val.span();
164                    let prev = self.isolation;
165                    self.isolation.update(val, path, errors);
166                    if errors.history_locked_after_startup()
167                        && self.isolation != errors.config().history.isolation
168                    {
169                        self.isolation = prev;
170                        errors.locked_after_startup(path, val.span());
171                    }
172                }
173                "sync_on_enter" => self.sync_on_enter.update(val, path, errors),
174                "max_size" => {
175                    let prev = self.max_size;
176                    self.max_size.update(val, path, errors);
177                    if errors.history_locked_after_startup()
178                        && self.max_size != errors.config().history.max_size
179                    {
180                        self.max_size = prev;
181                        errors.locked_after_startup(path, val.span());
182                    }
183                }
184                "file_format" => {
185                    let prev = self.file_format;
186                    self.file_format.update(val, path, errors);
187                    if errors.history_locked_after_startup()
188                        && self.file_format != errors.config().history.file_format
189                    {
190                        self.file_format = prev;
191                        errors.locked_after_startup(path, val.span());
192                    }
193                }
194                "path" => match val {
195                    Value::String { val: s, .. } => {
196                        let new_path = if s.is_empty() {
197                            HistoryPath::Default
198                        } else {
199                            HistoryPath::Custom(PathBuf::from(s))
200                        };
201
202                        if errors.history_locked_after_startup()
203                            && new_path != errors.config().history.path
204                        {
205                            errors.locked_after_startup(path, val.span());
206                            continue;
207                        }
208
209                        self.path = new_path;
210                    }
211                    Value::Nothing { .. } => {
212                        if errors.history_locked_after_startup()
213                            && errors.config().history.path != HistoryPath::Disabled
214                        {
215                            errors.locked_after_startup(path, val.span());
216                            continue;
217                        }
218
219                        self.path = HistoryPath::Disabled;
220                    }
221                    _ => {
222                        errors.type_mismatch(path, Type::custom("string or nothing"), val);
223                    }
224                },
225                "ignore_space_prefixed" => self.ignore_space_prefixed.update(val, path, errors),
226                _ => errors.unknown_option(path, val),
227            }
228        }
229
230        // Listing all formats separately in case additional ones are added
231        match (self.isolation, self.file_format) {
232            (true, HistoryFileFormat::Plaintext) => {
233                errors.warn(ConfigWarning::IncompatibleOptions {
234                    label: "history isolation only compatible with SQLite format",
235                    span: isolation_span,
236                    help: r#"disable history isolation, or set $env.config.history.file_format = "sqlite""#,
237                });
238            }
239            (true, HistoryFileFormat::Sqlite) => (),
240            (false, _) => (),
241        }
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use crate::Config;
249
250    fn config_with_history_path(path: HistoryPath) -> Config {
251        let mut config = Config::default();
252        config.history.path = path;
253        config
254    }
255
256    #[test]
257    fn lock_blocks_changing_to_custom_path() {
258        let old = config_with_history_path(HistoryPath::Default);
259        let mut new = old.clone();
260        let value = Value::test_record(record! {
261            "history" => Value::test_record(record! {
262                "path" => Value::test_string("/tmp/locked.txt"),
263            }),
264        });
265
266        let result = new.update_from_value_with_options(&old, &value, true);
267
268        let err = result.expect_err("should fail when locked");
269        let msg = format!("{err:?}");
270        assert!(
271            msg.contains("LockedAfterStartup"),
272            "expected LockedAfterStartup error, got: {msg}",
273        );
274        assert_eq!(new.history.path, HistoryPath::Default);
275    }
276
277    #[test]
278    fn lock_blocks_disabling_history_at_runtime() {
279        let old = config_with_history_path(HistoryPath::Custom("/tmp/h.txt".into()));
280        let mut new = old.clone();
281        let value = Value::test_record(record! {
282            "history" => Value::test_record(record! {
283                "path" => Value::nothing(Span::test_data()),
284            }),
285        });
286
287        let result = new.update_from_value_with_options(&old, &value, true);
288
289        let err = result.expect_err("should fail when locked");
290        let msg = format!("{err:?}");
291        assert!(
292            msg.contains("LockedAfterStartup"),
293            "expected LockedAfterStartup error, got: {msg}",
294        );
295        assert_eq!(new.history.path, HistoryPath::Custom("/tmp/h.txt".into()));
296    }
297
298    #[test]
299    fn lock_allows_setting_same_value() {
300        let old = config_with_history_path(HistoryPath::Custom("/tmp/h.txt".into()));
301        let mut new = old.clone();
302        let value = Value::test_record(record! {
303            "history" => Value::test_record(record! {
304                "path" => Value::test_string("/tmp/h.txt"),
305            }),
306        });
307
308        let result = new.update_from_value_with_options(&old, &value, true);
309
310        assert!(
311            result.is_ok(),
312            "no-op assignment should succeed: {result:?}"
313        );
314        assert_eq!(new.history.path, HistoryPath::Custom("/tmp/h.txt".into()));
315    }
316
317    #[test]
318    fn lock_allows_setting_default_when_already_default() {
319        let old = config_with_history_path(HistoryPath::Default);
320        let mut new = old.clone();
321        let value = Value::test_record(record! {
322            "history" => Value::test_record(record! {
323                "path" => Value::test_string(""),
324            }),
325        });
326
327        let result = new.update_from_value_with_options(&old, &value, true);
328
329        assert!(
330            result.is_ok(),
331            "no-op assignment should succeed: {result:?}"
332        );
333        assert_eq!(new.history.path, HistoryPath::Default);
334    }
335
336    #[test]
337    fn unlocked_update_changes_path() {
338        let old = config_with_history_path(HistoryPath::Default);
339        let mut new = old.clone();
340        let value = Value::test_record(record! {
341            "history" => Value::test_record(record! {
342                "path" => Value::test_string("/tmp/unlocked.txt"),
343            }),
344        });
345
346        let result = new.update_from_value_with_options(&old, &value, false);
347
348        assert!(result.is_ok(), "unlocked update should succeed: {result:?}");
349        assert_eq!(
350            new.history.path,
351            HistoryPath::Custom("/tmp/unlocked.txt".into())
352        );
353    }
354
355    #[test]
356    fn lock_blocks_changing_max_size() {
357        let old = Config::default();
358        let original_max_size = old.history.max_size;
359        let mut new = old.clone();
360        let value = Value::test_record(record! {
361            "history" => Value::test_record(record! {
362                "max_size" => Value::test_int(original_max_size + 1),
363            }),
364        });
365
366        let result = new.update_from_value_with_options(&old, &value, true);
367
368        let err = result.expect_err("should fail when locked");
369        let msg = format!("{err:?}");
370        assert!(
371            msg.contains("LockedAfterStartup"),
372            "expected LockedAfterStartup error, got: {msg}",
373        );
374        assert_eq!(new.history.max_size, original_max_size);
375    }
376
377    #[test]
378    fn lock_allows_setting_same_max_size() {
379        let old = Config::default();
380        let original_max_size = old.history.max_size;
381        let mut new = old.clone();
382        let value = Value::test_record(record! {
383            "history" => Value::test_record(record! {
384                "max_size" => Value::test_int(original_max_size),
385            }),
386        });
387
388        let result = new.update_from_value_with_options(&old, &value, true);
389
390        assert!(
391            result.is_ok(),
392            "no-op assignment should succeed: {result:?}"
393        );
394        assert_eq!(new.history.max_size, original_max_size);
395    }
396
397    #[cfg(feature = "sqlite")]
398    #[test]
399    fn lock_blocks_changing_file_format() {
400        let old = Config::default();
401        let original_file_format = old.history.file_format;
402        let (_other_format, other_format_str) = match original_file_format {
403            HistoryFileFormat::Plaintext => (HistoryFileFormat::Sqlite, "sqlite"),
404            HistoryFileFormat::Sqlite => (HistoryFileFormat::Plaintext, "plaintext"),
405        };
406        let mut new = old.clone();
407        let value = Value::test_record(record! {
408            "history" => Value::test_record(record! {
409                "file_format" => Value::test_string(other_format_str),
410            }),
411        });
412
413        let result = new.update_from_value_with_options(&old, &value, true);
414
415        let err = result.expect_err("should fail when locked");
416        let msg = format!("{err:?}");
417        assert!(
418            msg.contains("LockedAfterStartup"),
419            "expected LockedAfterStartup error, got: {msg}",
420        );
421        assert_eq!(new.history.file_format, original_file_format);
422    }
423
424    #[test]
425    fn lock_blocks_changing_isolation() {
426        let old = Config::default();
427        let original_isolation = old.history.isolation;
428        let mut new = old.clone();
429        let value = Value::test_record(record! {
430            "history" => Value::test_record(record! {
431                "isolation" => Value::test_bool(!original_isolation),
432            }),
433        });
434
435        let result = new.update_from_value_with_options(&old, &value, true);
436
437        let err = result.expect_err("should fail when locked");
438        let msg = format!("{err:?}");
439        assert!(
440            msg.contains("LockedAfterStartup"),
441            "expected LockedAfterStartup error, got: {msg}",
442        );
443        assert_eq!(new.history.isolation, original_isolation);
444    }
445
446    #[cfg(feature = "sqlite")]
447    #[test]
448    fn unlocked_update_changes_max_size_file_format_isolation() {
449        let old = Config::default();
450        let mut new = old.clone();
451        let new_max_size = old.history.max_size + 1;
452        let (new_file_format, new_file_format_str) = match old.history.file_format {
453            HistoryFileFormat::Plaintext => (HistoryFileFormat::Sqlite, "sqlite"),
454            HistoryFileFormat::Sqlite => (HistoryFileFormat::Plaintext, "plaintext"),
455        };
456        let new_isolation = !old.history.isolation;
457        let value = Value::test_record(record! {
458            "history" => Value::test_record(record! {
459                "max_size" => Value::test_int(new_max_size),
460                "file_format" => Value::test_string(new_file_format_str),
461                "isolation" => Value::test_bool(new_isolation),
462            }),
463        });
464
465        let result = new.update_from_value_with_options(&old, &value, false);
466
467        assert!(result.is_ok(), "unlocked update should succeed: {result:?}");
468        assert_eq!(new.history.max_size, new_max_size);
469        assert_eq!(new.history.file_format, new_file_format);
470        assert_eq!(new.history.isolation, new_isolation);
471    }
472}