diff --git a/Cargo.lock b/Cargo.lock index c0f20c0380..ac4bdb3ced 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3026,6 +3026,7 @@ dependencies = [ "rstest", "sysinfo 0.32.0", "tempfile", + "test-case", "unicode-segmentation", "uuid", "which", @@ -6237,6 +6238,39 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" +[[package]] +name = "test-case" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2550dd13afcd286853192af8601920d959b14c401fcece38071d53bf0768a8" +dependencies = [ + "test-case-macros", +] + +[[package]] +name = "test-case-core" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adcb7fd841cd518e279be3d5a3eb0636409487998a4aff22f3de87b81e88384f" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.75", +] + +[[package]] +name = "test-case-macros" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.75", + "test-case-core", +] + [[package]] name = "textwrap" version = "0.16.1" diff --git a/crates/nu-cli/Cargo.toml b/crates/nu-cli/Cargo.toml index 4fa99ada52..9529691c24 100644 --- a/crates/nu-cli/Cargo.toml +++ b/crates/nu-cli/Cargo.toml @@ -16,6 +16,7 @@ nu-command = { path = "../nu-command", version = "0.99.2" } nu-test-support = { path = "../nu-test-support", version = "0.99.2" } rstest = { workspace = true, default-features = false } tempfile = { workspace = true } +test-case = "3.3.1" [dependencies] nu-cmd-base = { path = "../nu-cmd-base", version = "0.99.2" } diff --git a/crates/nu-cli/src/commands/default_context.rs b/crates/nu-cli/src/commands/default_context.rs index 0c1c459c20..ad19f18d94 100644 --- a/crates/nu-cli/src/commands/default_context.rs +++ b/crates/nu-cli/src/commands/default_context.rs @@ -17,6 +17,7 @@ pub fn add_cli_context(mut engine_state: EngineState) -> EngineState { CommandlineGetCursor, CommandlineSetCursor, History, + HistoryImport, HistorySession, Keybindings, KeybindingsDefault, diff --git a/crates/nu-cli/src/commands/history/fields.rs b/crates/nu-cli/src/commands/history/fields.rs new file mode 100644 index 0000000000..a5b44224ce --- /dev/null +++ b/crates/nu-cli/src/commands/history/fields.rs @@ -0,0 +1,9 @@ +// Each const is named after a HistoryItem field, and the value is the field name to be displayed to +// the user (or accept during import). +pub const COMMAND_LINE: &str = "command"; +pub const START_TIMESTAMP: &str = "start_timestamp"; +pub const HOSTNAME: &str = "hostname"; +pub const CWD: &str = "cwd"; +pub const EXIT_STATUS: &str = "exit_status"; +pub const DURATION: &str = "duration"; +pub const SESSION_ID: &str = "session_id"; diff --git a/crates/nu-cli/src/commands/history/history_.rs b/crates/nu-cli/src/commands/history/history_.rs index 602574229e..40d951966e 100644 --- a/crates/nu-cli/src/commands/history/history_.rs +++ b/crates/nu-cli/src/commands/history/history_.rs @@ -5,6 +5,8 @@ use reedline::{ SqliteBackedHistory, }; +use super::fields; + #[derive(Clone)] pub struct History; @@ -83,7 +85,8 @@ impl Command for History { entries.into_iter().enumerate().map(move |(idx, entry)| { Value::record( record! { - "command" => Value::string(entry.command_line, head), + fields::COMMAND_LINE => Value::string(entry.command_line, head), + // TODO: This name is inconsistent with create_history_record. "index" => Value::int(idx as i64, head), }, head, @@ -176,13 +179,13 @@ fn create_history_record(idx: usize, entry: HistoryItem, long: bool, head: Span) Value::record( record! { "item_id" => item_id_value, - "start_timestamp" => start_timestamp_value, - "command" => command_value, - "session_id" => session_id_value, - "hostname" => hostname_value, - "cwd" => cwd_value, - "duration" => duration_value, - "exit_status" => exit_status_value, + fields::START_TIMESTAMP => start_timestamp_value, + fields::COMMAND_LINE => command_value, + fields::SESSION_ID => session_id_value, + fields::HOSTNAME => hostname_value, + fields::CWD => cwd_value, + fields::DURATION => duration_value, + fields::EXIT_STATUS => exit_status_value, "idx" => index_value, }, head, @@ -190,11 +193,11 @@ fn create_history_record(idx: usize, entry: HistoryItem, long: bool, head: Span) } else { Value::record( record! { - "start_timestamp" => start_timestamp_value, - "command" => command_value, - "cwd" => cwd_value, - "duration" => duration_value, - "exit_status" => exit_status_value, + fields::START_TIMESTAMP => start_timestamp_value, + fields::COMMAND_LINE => command_value, + fields::CWD => cwd_value, + fields::DURATION => duration_value, + fields::EXIT_STATUS => exit_status_value, }, head, ) diff --git a/crates/nu-cli/src/commands/history/history_import.rs b/crates/nu-cli/src/commands/history/history_import.rs new file mode 100644 index 0000000000..332f5d13fc --- /dev/null +++ b/crates/nu-cli/src/commands/history/history_import.rs @@ -0,0 +1,418 @@ +use std::path::{Path, PathBuf}; + +use nu_engine::command_prelude::*; +use nu_protocol::HistoryFileFormat; + +use reedline::{ + FileBackedHistory, History, HistoryItem, ReedlineError, SearchQuery, SqliteBackedHistory, +}; + +use super::fields; + +#[derive(Clone)] +pub struct HistoryImport; + +impl Command for HistoryImport { + fn name(&self) -> &str { + "history import" + } + + fn description(&self) -> &str { + "Import command line history" + } + + fn extra_description(&self) -> &str { + r#"Can import history from input, either successive command lines or more detailed records. If providing records, available fields are: + command_line, id, start_timestamp, hostname, cwd, duration, exit_status. + +If no input is provided, will import all history items from existing history in the other format: if current history is stored in sqlite, it will store it in plain text and vice versa. + +Note that history item IDs are ignored when importing from file."# + } + + fn signature(&self) -> nu_protocol::Signature { + Signature::build("history import") + .category(Category::History) + .input_output_types(vec![ + (Type::Nothing, Type::Nothing), + (Type::List(Box::new(Type::String)), Type::Nothing), + (Type::table(), Type::Nothing), + ]) + } + + fn examples(&self) -> Vec { + vec![ + Example { + example: "history import", + description: + "Append all items from history in the other format to the current history", + result: None, + }, + Example { + example: "echo foo | history import", + description: "Append `foo` to the current history", + result: None, + }, + Example { + example: "[[ command_line cwd ]; [ foo /home ]] | history import", + description: "Append `foo` ran from `/home` to the current history", + result: None, + }, + ] + } + + fn run( + &self, + engine_state: &EngineState, + _stack: &mut Stack, + call: &Call, + input: PipelineData, + ) -> Result { + let ok = Ok(Value::nothing(call.head).into_pipeline_data()); + + let Some(history) = engine_state.history_config() else { + return ok; + }; + let Some(current_history_path) = history.file_path() else { + return Err(ShellError::ConfigDirNotFound { + span: Some(call.head), + }); + }; + if let Some(bak_path) = backup(¤t_history_path)? { + println!("Backed history to {}", bak_path.display()); + } + match input { + PipelineData::Empty => { + let other_format = match history.file_format { + HistoryFileFormat::Sqlite => HistoryFileFormat::Plaintext, + HistoryFileFormat::Plaintext => HistoryFileFormat::Sqlite, + }; + let src = new_backend(other_format, None)?; + let mut dst = new_backend(history.file_format, Some(current_history_path))?; + let items = src + .search(SearchQuery::everything( + reedline::SearchDirection::Forward, + None, + )) + .map_err(error_from_reedline)? + .into_iter() + .map(Ok); + import(dst.as_mut(), items) + } + _ => { + let input = input.into_iter().map(item_from_value); + import( + new_backend(history.file_format, Some(current_history_path))?.as_mut(), + input, + ) + } + }?; + + ok + } +} + +fn new_backend( + format: HistoryFileFormat, + path: Option, +) -> Result, ShellError> { + let path = match path { + Some(path) => path, + None => { + let Some(mut path) = nu_path::nu_config_dir() else { + return Err(ShellError::ConfigDirNotFound { span: None }); + }; + path.push(format.default_file_name()); + path.into_std_path_buf() + } + }; + + fn map( + result: Result, + ) -> Result, ShellError> { + result + .map(|x| Box::new(x) as Box) + .map_err(error_from_reedline) + } + match format { + // Use a reasonably large value for maximum capacity. + HistoryFileFormat::Plaintext => map(FileBackedHistory::with_file(0xfffffff, path)), + HistoryFileFormat::Sqlite => map(SqliteBackedHistory::with_file(path, None, None)), + } +} + +fn import( + dst: &mut dyn History, + src: impl Iterator>, +) -> Result<(), ShellError> { + for item in src { + let mut item = item?; + item.id = None; + dst.save(item).map_err(error_from_reedline)?; + } + Ok(()) +} + +fn error_from_reedline(e: ReedlineError) -> ShellError { + // TODO: Should we add a new ShellError variant? + ShellError::GenericError { + error: "Reedline error".to_owned(), + msg: format!("{e}"), + span: None, + help: None, + inner: Vec::new(), + } +} + +fn item_from_value(v: Value) -> Result { + let span = v.span(); + match v { + Value::Record { val, .. } => item_from_record(val.into_owned(), span), + Value::String { val, .. } => Ok(HistoryItem { + command_line: val, + id: None, + start_timestamp: None, + session_id: None, + hostname: None, + cwd: None, + duration: None, + exit_status: None, + more_info: None, + }), + _ => Err(ShellError::UnsupportedInput { + msg: "Only list and record inputs are supported".to_owned(), + input: v.get_type().to_string(), + msg_span: span, + input_span: span, + }), + } +} + +fn item_from_record(mut rec: Record, span: Span) -> Result { + let cmd = match rec.remove(fields::COMMAND_LINE) { + Some(v) => v.as_str()?.to_owned(), + None => { + return Err(ShellError::TypeMismatch { + err_message: format!("missing column: {}", fields::COMMAND_LINE), + span, + }) + } + }; + + fn get( + rec: &mut Record, + field: &'static str, + f: impl FnOnce(Value) -> Result, + ) -> Result, ShellError> { + rec.remove(field).map(f).transpose() + } + + let rec = &mut rec; + let item = HistoryItem { + command_line: cmd, + id: None, + start_timestamp: get(rec, fields::START_TIMESTAMP, |v| Ok(v.as_date()?.to_utc()))?, + hostname: get(rec, fields::HOSTNAME, |v| Ok(v.as_str()?.to_owned()))?, + cwd: get(rec, fields::CWD, |v| Ok(v.as_str()?.to_owned()))?, + exit_status: get(rec, fields::EXIT_STATUS, |v| v.as_i64())?, + duration: get(rec, fields::DURATION, duration_from_value)?, + more_info: None, + // TODO: Currently reedline doesn't let you create session IDs. + session_id: None, + }; + + if !rec.is_empty() { + let cols = rec.columns().map(|s| s.as_str()).collect::>(); + return Err(ShellError::TypeMismatch { + err_message: format!("unsupported column names: {}", cols.join(", ")), + span, + }); + } + Ok(item) +} + +fn duration_from_value(v: Value) -> Result { + chrono::Duration::nanoseconds(v.as_duration()?) + .to_std() + .map_err(|_| ShellError::IOError { + msg: "negative duration not supported".to_string(), + }) +} + +fn find_backup_path(path: &Path) -> Result { + let Ok(mut bak_path) = path.to_path_buf().into_os_string().into_string() else { + // This isn't fundamentally problem, but trying to work with OsString is a nightmare. + return Err(ShellError::IOError { + msg: "History path mush be representable as UTF-8".to_string(), + }); + }; + bak_path.push_str(".bak"); + if !Path::new(&bak_path).exists() { + return Ok(bak_path.into()); + } + let base_len = bak_path.len(); + for i in 1..100 { + use std::fmt::Write; + bak_path.truncate(base_len); + write!(&mut bak_path, ".{i}").unwrap(); + if !Path::new(&bak_path).exists() { + return Ok(PathBuf::from(bak_path)); + } + } + Err(ShellError::IOError { + msg: "Too many existing backup files".to_string(), + }) +} + +fn backup(path: &Path) -> Result, ShellError> { + match path.metadata() { + Ok(md) if md.is_file() => (), + Ok(_) => { + return Err(ShellError::IOError { + msg: "history path exists but is not a file".to_string(), + }) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e.into()), + } + let bak_path = find_backup_path(path)?; + std::fs::copy(path, &bak_path)?; + Ok(Some(bak_path)) +} + +#[cfg(test)] +mod tests { + use chrono::DateTime; + use test_case::case; + + use super::*; + + #[test] + fn test_item_from_value_string() -> Result<(), ShellError> { + let item = item_from_value(Value::string("foo", Span::unknown()))?; + assert_eq!( + item, + HistoryItem { + command_line: "foo".to_string(), + id: None, + start_timestamp: None, + session_id: None, + hostname: None, + cwd: None, + duration: None, + exit_status: None, + more_info: None + } + ); + Ok(()) + } + + #[test] + fn test_item_from_value_record() { + let span = Span::unknown(); + let rec = new_record(&[ + ("command", Value::string("foo", span)), + ( + "start_timestamp", + Value::date( + DateTime::parse_from_rfc3339("1996-12-19T16:39:57-08:00").unwrap(), + span, + ), + ), + ("hostname", Value::string("localhost", span)), + ("cwd", Value::string("/home/test", span)), + ("duration", Value::duration(100_000_000, span)), + ("exit_status", Value::int(42, span)), + ]); + let item = item_from_value(rec).unwrap(); + assert_eq!( + item, + HistoryItem { + command_line: "foo".to_string(), + id: None, + start_timestamp: Some( + DateTime::parse_from_rfc3339("1996-12-19T16:39:57-08:00") + .unwrap() + .to_utc() + ), + hostname: Some("localhost".to_string()), + cwd: Some("/home/test".to_string()), + duration: Some(std::time::Duration::from_nanos(100_000_000)), + exit_status: Some(42), + + session_id: None, + more_info: None + } + ); + } + + #[test] + fn test_item_from_value_record_extra_field() { + let span = Span::unknown(); + let rec = new_record(&[ + ("command_line", Value::string("foo", span)), + ("id_nonexistent", Value::int(1, span)), + ]); + assert!(item_from_value(rec).is_err()); + } + + #[test] + fn test_item_from_value_record_bad_type() { + let span = Span::unknown(); + let rec = new_record(&[ + ("command_line", Value::string("foo", span)), + ("id", Value::string("one".to_string(), span)), + ]); + assert!(item_from_value(rec).is_err()); + } + + fn new_record(rec: &[(&'static str, Value)]) -> Value { + let span = Span::unknown(); + let rec = Record::from_raw_cols_vals( + rec.iter().map(|(col, _)| col.to_string()).collect(), + rec.iter().map(|(_, val)| val.clone()).collect(), + span, + span, + ) + .unwrap(); + Value::record(rec, span) + } + + #[case(&["history.dat"], "history.dat.bak"; "no_backup")] + #[case(&["history.dat", "history.dat.bak"], "history.dat.bak.1"; "backup_exists")] + #[case( + &["history.dat", "history.dat.bak", "history.dat.bak.1"], + "history.dat.bak.2"; + "multiple_backups_exists" + )] + fn test_find_backup_path(existing: &[&str], want: &str) { + let dir = tempfile::tempdir().unwrap(); + for name in existing { + std::fs::File::create_new(dir.path().join(name)).unwrap(); + } + let got = find_backup_path(&dir.path().join("history.dat")).unwrap(); + assert_eq!(got, dir.path().join(want)) + } + + #[test] + fn test_backup() { + let dir = tempfile::tempdir().unwrap(); + let mut history = std::fs::File::create_new(dir.path().join("history.dat")).unwrap(); + use std::io::Write; + write!(&mut history, "123").unwrap(); + let want_bak_path = dir.path().join("history.dat.bak"); + assert_eq!( + backup(&dir.path().join("history.dat")), + Ok(Some(want_bak_path.clone())) + ); + let got_data = String::from_utf8(std::fs::read(want_bak_path).unwrap()).unwrap(); + assert_eq!(got_data, "123"); + } + + #[test] + fn test_backup_no_file() { + let dir = tempfile::tempdir().unwrap(); + let bak_path = backup(&dir.path().join("history.dat")).unwrap(); + assert!(bak_path.is_none()); + } +} diff --git a/crates/nu-cli/src/commands/history/mod.rs b/crates/nu-cli/src/commands/history/mod.rs index be7d1fc11f..c36b560307 100644 --- a/crates/nu-cli/src/commands/history/mod.rs +++ b/crates/nu-cli/src/commands/history/mod.rs @@ -1,5 +1,8 @@ +mod fields; mod history_; +mod history_import; mod history_session; pub use history_::History; +pub use history_import::HistoryImport; pub use history_session::HistorySession; diff --git a/crates/nu-cli/src/commands/mod.rs b/crates/nu-cli/src/commands/mod.rs index f63724e95f..4a9dd9ef21 100644 --- a/crates/nu-cli/src/commands/mod.rs +++ b/crates/nu-cli/src/commands/mod.rs @@ -7,7 +7,7 @@ mod keybindings_list; mod keybindings_listen; pub use commandline::{Commandline, CommandlineEdit, CommandlineGetCursor, CommandlineSetCursor}; -pub use history::{History, HistorySession}; +pub use history::{History, HistoryImport, HistorySession}; pub use keybindings::Keybindings; pub use keybindings_default::KeybindingsDefault; pub use keybindings_list::KeybindingsList; diff --git a/crates/nu-cli/tests/commands/history_import.rs b/crates/nu-cli/tests/commands/history_import.rs new file mode 100644 index 0000000000..c20bf2624b --- /dev/null +++ b/crates/nu-cli/tests/commands/history_import.rs @@ -0,0 +1,295 @@ +use nu_protocol::HistoryFileFormat; +use nu_test_support::{nu, Outcome}; +use reedline::{ + FileBackedHistory, History, HistoryItem, HistoryItemId, ReedlineError, SearchQuery, + SqliteBackedHistory, +}; +use tempfile::TempDir; +use test_case::case; + +struct Test { + cfg_dir: TempDir, +} + +impl Test { + fn new(history_format: &'static str) -> Self { + let cfg_dir = tempfile::Builder::new() + .prefix("history_import_test") + .tempdir() + .unwrap(); + // Assigning to $env.config.history.file_format seems to work only in startup + // configuration. + std::fs::write( + cfg_dir.path().join("env.nu"), + format!("$env.config.history.file_format = {history_format:?}"), + ) + .unwrap(); + Self { cfg_dir } + } + + fn nu(&self, cmd: impl AsRef) -> Outcome { + let env = [( + "XDG_CONFIG_HOME".to_string(), + self.cfg_dir.path().to_str().unwrap().to_string(), + )]; + let env_config = self.cfg_dir.path().join("env.nu"); + nu!(envs: env, env_config: env_config, cmd.as_ref()) + } + + fn open_plaintext(&self) -> Result { + FileBackedHistory::with_file( + 100, + self.cfg_dir + .path() + .join("nushell") + .join(HistoryFileFormat::Plaintext.default_file_name()), + ) + } + + fn open_sqlite(&self) -> Result { + SqliteBackedHistory::with_file( + self.cfg_dir + .path() + .join("nushell") + .join(HistoryFileFormat::Sqlite.default_file_name()), + None, + None, + ) + } + + fn open_backend(&self, format: HistoryFileFormat) -> Result, ReedlineError> { + fn boxed(be: impl History + 'static) -> Box { + Box::new(be) + } + use HistoryFileFormat::*; + match format { + Plaintext => self.open_plaintext().map(boxed), + Sqlite => self.open_sqlite().map(boxed), + } + } +} + +enum HistorySource { + Vec(Vec), + Command(&'static str), +} + +struct TestCase { + dst_format: HistoryFileFormat, + dst_history: Vec, + src_history: HistorySource, + want_history: Vec, +} + +const EMPTY_TEST_CASE: TestCase = TestCase { + dst_format: HistoryFileFormat::Plaintext, + dst_history: Vec::new(), + src_history: HistorySource::Vec(Vec::new()), + want_history: Vec::new(), +}; + +impl TestCase { + fn run(self) { + use HistoryFileFormat::*; + let test = Test::new(match self.dst_format { + Plaintext => "plaintext", + Sqlite => "sqlite", + }); + save_all( + &mut *test.open_backend(self.dst_format).unwrap(), + self.dst_history, + ) + .unwrap(); + + let outcome = match self.src_history { + HistorySource::Vec(src_history) => { + let src_format = match self.dst_format { + Plaintext => Sqlite, + Sqlite => Plaintext, + }; + save_all(&mut *test.open_backend(src_format).unwrap(), src_history).unwrap(); + test.nu("history import") + } + HistorySource::Command(cmd) => { + let mut cmd = cmd.to_string(); + cmd.push_str(" | history import"); + test.nu(cmd) + } + }; + assert!(outcome.status.success()); + let got = query_all(&*test.open_backend(self.dst_format).unwrap()).unwrap(); + + // Compare just the commands first, for readability. + fn commands_only(items: &[HistoryItem]) -> Vec<&str> { + items + .iter() + .map(|item| item.command_line.as_str()) + .collect() + } + assert_eq!(commands_only(&got), commands_only(&self.want_history)); + // If commands match, compare full items. + assert_eq!(got, self.want_history); + } +} + +fn query_all(history: &dyn History) -> Result, ReedlineError> { + history.search(SearchQuery::everything( + reedline::SearchDirection::Forward, + None, + )) +} + +fn save_all(history: &mut dyn History, items: Vec) -> Result<(), ReedlineError> { + for item in items { + history.save(item)?; + } + Ok(()) +} + +const EMPTY_ITEM: HistoryItem = HistoryItem { + command_line: String::new(), + id: None, + start_timestamp: None, + session_id: None, + hostname: None, + cwd: None, + duration: None, + exit_status: None, + more_info: None, +}; + +#[test] +fn history_import_pipe_string() { + TestCase { + dst_format: HistoryFileFormat::Plaintext, + src_history: HistorySource::Command("echo bar"), + want_history: vec![HistoryItem { + id: Some(HistoryItemId::new(0)), + command_line: "bar".to_string(), + ..EMPTY_ITEM + }], + ..EMPTY_TEST_CASE + } + .run(); +} + +#[test] +fn history_import_pipe_record() { + TestCase { + dst_format: HistoryFileFormat::Sqlite, + src_history: HistorySource::Command("[[cwd command]; [/tmp some_command]]"), + want_history: vec![HistoryItem { + id: Some(HistoryItemId::new(1)), + command_line: "some_command".to_string(), + cwd: Some("/tmp".to_string()), + ..EMPTY_ITEM + }], + ..EMPTY_TEST_CASE + } + .run(); +} + +#[test] +fn to_empty_plaintext() { + TestCase { + dst_format: HistoryFileFormat::Plaintext, + src_history: HistorySource::Vec(vec![ + HistoryItem { + command_line: "foo".to_string(), + ..EMPTY_ITEM + }, + HistoryItem { + command_line: "bar".to_string(), + ..EMPTY_ITEM + }, + ]), + want_history: vec![ + HistoryItem { + id: Some(HistoryItemId::new(0)), + command_line: "foo".to_string(), + ..EMPTY_ITEM + }, + HistoryItem { + id: Some(HistoryItemId::new(1)), + command_line: "bar".to_string(), + ..EMPTY_ITEM + }, + ], + ..EMPTY_TEST_CASE + } + .run() +} + +#[test] +fn to_empty_sqlite() { + TestCase { + dst_format: HistoryFileFormat::Sqlite, + src_history: HistorySource::Vec(vec![ + HistoryItem { + command_line: "foo".to_string(), + ..EMPTY_ITEM + }, + HistoryItem { + command_line: "bar".to_string(), + ..EMPTY_ITEM + }, + ]), + want_history: vec![ + HistoryItem { + id: Some(HistoryItemId::new(1)), + command_line: "foo".to_string(), + ..EMPTY_ITEM + }, + HistoryItem { + id: Some(HistoryItemId::new(2)), + command_line: "bar".to_string(), + ..EMPTY_ITEM + }, + ], + ..EMPTY_TEST_CASE + } + .run() +} + +#[case(HistoryFileFormat::Plaintext; "plaintext")] +#[case(HistoryFileFormat::Sqlite; "sqlite")] +fn to_existing(dst_format: HistoryFileFormat) { + TestCase { + dst_format, + dst_history: vec![ + HistoryItem { + id: Some(HistoryItemId::new(0)), + command_line: "original-1".to_string(), + ..EMPTY_ITEM + }, + HistoryItem { + id: Some(HistoryItemId::new(1)), + command_line: "original-2".to_string(), + ..EMPTY_ITEM + }, + ], + src_history: HistorySource::Vec(vec![HistoryItem { + id: Some(HistoryItemId::new(1)), + command_line: "new".to_string(), + ..EMPTY_ITEM + }]), + want_history: vec![ + HistoryItem { + id: Some(HistoryItemId::new(0)), + command_line: "original-1".to_string(), + ..EMPTY_ITEM + }, + HistoryItem { + id: Some(HistoryItemId::new(1)), + command_line: "original-2".to_string(), + ..EMPTY_ITEM + }, + HistoryItem { + id: Some(HistoryItemId::new(2)), + command_line: "new".to_string(), + ..EMPTY_ITEM + }, + ], + } + .run() +} diff --git a/crates/nu-cli/tests/commands/mod.rs b/crates/nu-cli/tests/commands/mod.rs index 087791302e..9eb18e3280 100644 --- a/crates/nu-cli/tests/commands/mod.rs +++ b/crates/nu-cli/tests/commands/mod.rs @@ -1,2 +1,3 @@ +mod history_import; mod keybindings_list; mod nu_highlight; diff --git a/crates/nu-test-support/src/macros.rs b/crates/nu-test-support/src/macros.rs index 2547b1f4c8..1ae7ce593e 100644 --- a/crates/nu-test-support/src/macros.rs +++ b/crates/nu-test-support/src/macros.rs @@ -234,7 +234,7 @@ macro_rules! nu_with_plugins { } use crate::{Outcome, NATIVE_PATH_ENV_VAR}; -use nu_path::{AbsolutePath, AbsolutePathBuf, Path}; +use nu_path::{AbsolutePath, AbsolutePathBuf, Path, PathBuf}; use std::{ ffi::OsStr, process::{Command, Stdio}, @@ -248,6 +248,10 @@ pub struct NuOpts { pub envs: Option>, pub collapse_output: Option, pub use_ir: Option, + // Note: At the time this was added, passing in a file path was more convenient. However, + // passing in file contents seems like a better API - consider this when adding new uses of + // this field. + pub env_config: Option, } pub fn nu_run_test(opts: NuOpts, commands: impl AsRef, with_std: bool) -> Outcome { @@ -278,8 +282,14 @@ pub fn nu_run_test(opts: NuOpts, commands: impl AsRef, with_std: bool) -> O command.envs(envs); } - // Ensure that the user's config doesn't interfere with the tests - command.arg("--no-config-file"); + match opts.env_config { + Some(path) => command.arg("--env-config").arg(path), + // TODO: This seems unnecessary: the code that runs for integration tests + // (run_commands) loads startup configs only if they are specified via flags explicitly or + // the shell is started as logging shell (which it is not in this case). + None => command.arg("--no-config-file"), + }; + if !with_std { command.arg("--no-std-lib"); }