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 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) -> 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 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 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}