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 Sqlite,
9 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, 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 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 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}