Add warning when using history isolation with non-SQLite history format (#16151)

# Description
This PR depends on #16147, use `git diff 132ikl/shell-warning
132ikl/isolation-warn` to see only changes from this PR

People seem to get tripped up by this a lot, and it's not exactly
intuitive, so I added a warning if you try to set
`$env.config.history.isolation = true` when using the plaintext file
format:

    Warning: nu:🐚:invalid_config

      ⚠ Encountered 1 warnings(s) when updating config

    Warning: nu:🐚:incompatible_options

      ⚠ Incompatible options
       ╭─[source:1:33]
     1 │ $env.config.history.isolation = true
       ·                                 ──┬─
       ·                                   ╰── history isolation only compatible with SQLite format
       ╰────
      help: disable history isolation, or set $env.config.history.file_format = "sqlite"


# User-Facing Changes
* Added a warning when using history isolation without using SQLite
history.

# Tests + Formatting
Added a test
This commit is contained in:
132ikl
2025-07-15 09:40:32 -04:00
committed by GitHub
parent 59ad605e22
commit c4e8e040ce
11 changed files with 115 additions and 36 deletions

View File

@ -1,10 +1,12 @@
use super::ConfigPath; use super::ConfigPath;
use crate::{Config, ConfigError, ShellError, Span, Type, Value}; use crate::{Config, ConfigError, ConfigWarning, ShellError, ShellWarning, Span, Type, Value};
#[derive(Debug)] #[derive(Debug)]
#[must_use]
pub(super) struct ConfigErrors<'a> { pub(super) struct ConfigErrors<'a> {
config: &'a Config, config: &'a Config,
errors: Vec<ConfigError>, errors: Vec<ConfigError>,
warnings: Vec<ConfigWarning>,
} }
impl<'a> ConfigErrors<'a> { impl<'a> ConfigErrors<'a> {
@ -12,17 +14,26 @@ impl<'a> ConfigErrors<'a> {
Self { Self {
config, config,
errors: Vec::new(), errors: Vec::new(),
warnings: Vec::new(),
} }
} }
pub fn is_empty(&self) -> bool { pub fn has_errors(&self) -> bool {
self.errors.is_empty() !self.errors.is_empty()
}
pub fn has_warnings(&self) -> bool {
!self.warnings.is_empty()
} }
pub fn error(&mut self, error: ConfigError) { pub fn error(&mut self, error: ConfigError) {
self.errors.push(error); self.errors.push(error);
} }
pub fn warn(&mut self, warning: ConfigWarning) {
self.warnings.push(warning);
}
pub fn type_mismatch(&mut self, path: &ConfigPath, expected: Type, actual: &Value) { pub fn type_mismatch(&mut self, path: &ConfigPath, expected: Type, actual: &Value) {
self.error(ConfigError::TypeMismatch { self.error(ConfigError::TypeMismatch {
path: path.to_string(), path: path.to_string(),
@ -75,13 +86,15 @@ impl<'a> ConfigErrors<'a> {
}); });
} }
pub fn into_shell_error(self) -> Option<ShellError> { pub fn check(self) -> Result<Option<ShellWarning>, ShellError> {
if self.is_empty() { match (self.has_errors(), self.has_warnings()) {
None (true, _) => Err(ShellError::InvalidConfig {
} else {
Some(ShellError::InvalidConfig {
errors: self.errors, errors: self.errors,
}) }),
(false, true) => Ok(Some(ShellWarning::InvalidConfig {
warnings: self.warnings,
})),
(false, false) => Ok(None),
} }
} }
} }

View File

@ -1,5 +1,5 @@
use super::{config_update_string_enum, prelude::*}; use super::{config_update_string_enum, prelude::*};
use crate as nu_protocol; use crate::{self as nu_protocol, ConfigWarning};
#[derive(Clone, Copy, Debug, IntoValue, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, IntoValue, PartialEq, Eq, Serialize, Deserialize)]
pub enum HistoryFileFormat { pub enum HistoryFileFormat {
@ -77,15 +77,35 @@ impl UpdateFromValue for HistoryConfig {
return; return;
}; };
// might not be correct if file format was changed away from sqlite rather than isolation,
// but this is an edge case and the span of the relevant value here should be close enough
let mut isolation_span = value.span();
for (col, val) in record.iter() { for (col, val) in record.iter() {
let path = &mut path.push(col); let path = &mut path.push(col);
match col.as_str() { match col.as_str() {
"isolation" => self.isolation.update(val, path, errors), "isolation" => {
isolation_span = val.span();
self.isolation.update(val, path, errors)
}
"sync_on_enter" => self.sync_on_enter.update(val, path, errors), "sync_on_enter" => self.sync_on_enter.update(val, path, errors),
"max_size" => self.max_size.update(val, path, errors), "max_size" => self.max_size.update(val, path, errors),
"file_format" => self.file_format.update(val, path, errors), "file_format" => self.file_format.update(val, path, errors),
_ => errors.unknown_option(path, val), _ => errors.unknown_option(path, val),
} }
} }
// Listing all formats separately in case additional ones are added
match (self.isolation, self.file_format) {
(true, HistoryFileFormat::Plaintext) => {
errors.warn(ConfigWarning::IncompatibleOptions {
label: "history isolation only compatible with SQLite format",
span: isolation_span,
help: r#"disable history isolation, or set $env.config.history.file_format = "sqlite""#,
});
}
(true, HistoryFileFormat::Sqlite) => (),
(false, _) => (),
}
} }
} }

View File

@ -1,7 +1,7 @@
//! Module containing the internal representation of user configuration //! Module containing the internal representation of user configuration
use crate as nu_protocol;
use crate::FromValue; use crate::FromValue;
use crate::{self as nu_protocol};
use helper::*; use helper::*;
use prelude::*; use prelude::*;
use std::collections::HashMap; use std::collections::HashMap;
@ -219,7 +219,11 @@ impl UpdateFromValue for Config {
} }
impl Config { impl Config {
pub fn update_from_value(&mut self, old: &Config, value: &Value) -> Option<ShellError> { pub fn update_from_value(
&mut self,
old: &Config,
value: &Value,
) -> Result<Option<ShellWarning>, ShellError> {
// Current behaviour is that config errors are displayed, but do not prevent the rest // Current behaviour is that config errors are displayed, but do not prevent the rest
// of the config from being updated (fields with errors are skipped/not updated). // of the config from being updated (fields with errors are skipped/not updated).
// Errors are simply collected one-by-one and wrapped into a ShellError variant at the end. // Errors are simply collected one-by-one and wrapped into a ShellError variant at the end.
@ -228,6 +232,6 @@ impl Config {
self.update(value, &mut path, &mut errors); self.update(value, &mut path, &mut errors);
errors.into_shell_error() errors.check()
} }
} }

View File

@ -147,7 +147,7 @@ mod tests {
let mut errors = ConfigErrors::new(&config); let mut errors = ConfigErrors::new(&config);
let mut result = PluginGcConfigs::default(); let mut result = PluginGcConfigs::default();
result.update(&input, &mut ConfigPath::new(), &mut errors); result.update(&input, &mut ConfigPath::new(), &mut errors);
assert!(errors.is_empty(), "errors: {errors:#?}"); assert!(!errors.has_errors(), "errors: {errors:#?}");
assert_eq!(expected, result); assert_eq!(expected, result);
} }

View File

@ -1,4 +1,4 @@
pub(super) use super::{ConfigPath, UpdateFromValue, error::ConfigErrors}; pub(super) use super::{ConfigPath, UpdateFromValue, error::ConfigErrors};
pub use crate::{IntoValue, ShellError, Span, Type, Value, record}; pub use crate::{IntoValue, ShellError, ShellWarning, Span, Type, Value, record};
pub use serde::{Deserialize, Serialize}; pub use serde::{Deserialize, Serialize};
pub use std::str::FromStr; pub use std::str::FromStr;

View File

@ -4,6 +4,7 @@ use crate::{
ArgumentStack, DEFAULT_OVERLAY_NAME, EngineState, ErrorHandlerStack, Redirection, ArgumentStack, DEFAULT_OVERLAY_NAME, EngineState, ErrorHandlerStack, Redirection,
StackCallArgGuard, StackCollectValueGuard, StackIoGuard, StackOutDest, StackCallArgGuard, StackCollectValueGuard, StackIoGuard, StackOutDest,
}, },
report_shell_warning,
}; };
use nu_utils::IgnoreCaseExt; use nu_utils::IgnoreCaseExt;
use std::{ use std::{
@ -212,18 +213,17 @@ impl Stack {
if let Some(value) = self.get_env_var(engine_state, "config") { if let Some(value) = self.get_env_var(engine_state, "config") {
let old = self.get_config(engine_state); let old = self.get_config(engine_state);
let mut config = (*old).clone(); let mut config = (*old).clone();
let error = config.update_from_value(&old, value); let result = config.update_from_value(&old, value);
// The config value is modified by the update, so we should add it again // The config value is modified by the update, so we should add it again
self.add_env_var("config".into(), config.clone().into_value(value.span())); self.add_env_var("config".into(), config.clone().into_value(value.span()));
self.config = Some(config.into()); self.config = Some(config.into());
match error { if let Some(warning) = result? {
None => Ok(()), report_shell_warning(engine_state, &warning);
Some(err) => Err(err),
} }
} else { } else {
self.config = None; self.config = None;
Ok(())
} }
Ok(())
} }
pub fn add_var(&mut self, var_id: VarId, value: Value) { pub fn add_var(&mut self, var_id: VarId, value: Value) {

View File

@ -1,3 +1,5 @@
use std::hash::Hash;
use crate::{ShellError, Span, Type}; use crate::{ShellError, Span, Type};
use miette::Diagnostic; use miette::Diagnostic;
use thiserror::Error; use thiserror::Error;
@ -54,3 +56,29 @@ pub enum ConfigError {
#[diagnostic(transparent)] #[diagnostic(transparent)]
ShellError(#[from] ShellError), ShellError(#[from] ShellError),
} }
/// Warnings which don't prevent config from being loaded, but we should inform the user about
#[derive(Clone, Debug, PartialEq, Error, Diagnostic)]
#[diagnostic(severity(Warning))]
pub enum ConfigWarning {
#[error("Incompatible options")]
#[diagnostic(code(nu::shell::incompatible_options), help("{help}"))]
IncompatibleOptions {
label: &'static str,
#[label = "{label}"]
span: Span,
help: &'static str,
},
}
// To keep track of reported warnings
impl Hash for ConfigWarning {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
match self {
ConfigWarning::IncompatibleOptions { label, help, .. } => {
label.hash(state);
help.hash(state);
}
}
}
}

View File

@ -1,6 +1,6 @@
mod chained_error; mod chained_error;
mod compile_error; mod compile_error;
mod config_error; mod config;
mod labeled_error; mod labeled_error;
mod parse_error; mod parse_error;
mod parse_warning; mod parse_warning;
@ -9,7 +9,7 @@ pub mod shell_error;
pub mod shell_warning; pub mod shell_warning;
pub use compile_error::CompileError; pub use compile_error::CompileError;
pub use config_error::ConfigError; pub use config::{ConfigError, ConfigWarning};
pub use labeled_error::{ErrorLabel, LabeledError}; pub use labeled_error::{ErrorLabel, LabeledError};
pub use parse_error::{DidYouMean, ParseError}; pub use parse_error::{DidYouMean, ParseError};
pub use parse_warning::ParseWarning; pub use parse_warning::ParseWarning;

View File

@ -1,14 +1,16 @@
use crate::Span; use crate::Span;
use miette::Diagnostic; use miette::Diagnostic;
use serde::{Deserialize, Serialize};
use std::hash::Hash; use std::hash::Hash;
use thiserror::Error; use thiserror::Error;
use crate::{ReportMode, Reportable}; use crate::{ReportMode, Reportable};
#[derive(Clone, Debug, Error, Diagnostic, Serialize, Deserialize)] #[derive(Clone, Debug, Error, Diagnostic)]
#[diagnostic(severity(Warning))] #[diagnostic(severity(Warning))]
pub enum ParseWarning { pub enum ParseWarning {
/// A parse-time deprectaion. Indicates that something will be removed in a future release.
///
/// Use [`ShellWarning::Deprecated`] if this is a deprecation which is only detectable at run-time.
#[error("{dep_type} deprecated.")] #[error("{dep_type} deprecated.")]
#[diagnostic(code(nu::parser::deprecated))] #[diagnostic(code(nu::parser::deprecated))]
Deprecated { Deprecated {

View File

@ -1,14 +1,16 @@
use crate::Span; use crate::Span;
use miette::Diagnostic; use miette::Diagnostic;
use serde::{Deserialize, Serialize};
use std::hash::Hash; use std::hash::Hash;
use thiserror::Error; use thiserror::Error;
use crate::{ReportMode, Reportable}; use crate::{ConfigWarning, ReportMode, Reportable};
#[derive(Clone, Debug, Error, Diagnostic, Serialize, Deserialize)] #[derive(Clone, Debug, Error, Diagnostic)]
#[diagnostic(severity(Warning))] #[diagnostic(severity(Warning))]
pub enum ShellWarning { pub enum ShellWarning {
/// A parse-time deprectaion. Indicates that something will be removed in a future release.
///
/// Use [`ParseWarning::Deprecated`] if this is a deprecation which is detectable at parse-time.
#[error("{dep_type} deprecated.")] #[error("{dep_type} deprecated.")]
#[diagnostic(code(nu::shell::deprecated))] #[diagnostic(code(nu::shell::deprecated))]
Deprecated { Deprecated {
@ -20,20 +22,20 @@ pub enum ShellWarning {
help: Option<String>, help: Option<String>,
report_mode: ReportMode, report_mode: ReportMode,
}, },
} /// Warnings reported while updating the config
#[error("Encountered {} warnings(s) when updating config", warnings.len())]
impl ShellWarning { #[diagnostic(code(nu::shell::invalid_config))]
pub fn span(&self) -> Span { InvalidConfig {
match self { #[related]
ShellWarning::Deprecated { span, .. } => *span, warnings: Vec<ConfigWarning>,
} },
}
} }
impl Reportable for ShellWarning { impl Reportable for ShellWarning {
fn report_mode(&self) -> ReportMode { fn report_mode(&self) -> ReportMode {
match self { match self {
ShellWarning::Deprecated { report_mode, .. } => *report_mode, ShellWarning::Deprecated { report_mode, .. } => *report_mode,
ShellWarning::InvalidConfig { .. } => ReportMode::FirstUse,
} }
} }
} }
@ -48,6 +50,8 @@ impl Hash for ShellWarning {
dep_type.hash(state); dep_type.hash(state);
label.hash(state); label.hash(state);
} }
// We always report config warnings, so no hash necessary
ShellWarning::InvalidConfig { .. } => (),
} }
} }
} }

View File

@ -168,3 +168,11 @@ fn mutate_nu_config_plugin_gc_plugins() -> TestResult {
"0sec", "0sec",
) )
} }
#[test]
fn mutate_nu_config_history_warning() -> TestResult {
fail_test(
r#"$env.config.history.file_format = "plaintext"; $env.config.history.isolation = true"#,
"history isolation only compatible with SQLite format",
)
}