mirror of
https://github.com/nushell/nushell
synced 2024-12-26 04:53:09 +00:00
Add the history import command (again) (#14083)
# Description This is mainly https://github.com/nushell/nushell/pull/13450 (which got reverted). Additionally: - always clear IDs on import, disallow specifying IDs when piping - added extra tests - create backup of the history # User-Facing Changes New command: `history import` # Tests + Formatting Added mostly integration tests and a few smaller unit tests.
This commit is contained in:
parent
719d9aa83c
commit
f8d4adfb7a
11 changed files with 792 additions and 17 deletions
34
Cargo.lock
generated
34
Cargo.lock
generated
|
@ -3026,6 +3026,7 @@ dependencies = [
|
||||||
"rstest",
|
"rstest",
|
||||||
"sysinfo 0.32.0",
|
"sysinfo 0.32.0",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
"test-case",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"uuid",
|
"uuid",
|
||||||
"which",
|
"which",
|
||||||
|
@ -6237,6 +6238,39 @@ version = "0.4.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76"
|
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]]
|
[[package]]
|
||||||
name = "textwrap"
|
name = "textwrap"
|
||||||
version = "0.16.1"
|
version = "0.16.1"
|
||||||
|
|
|
@ -16,6 +16,7 @@ nu-command = { path = "../nu-command", version = "0.99.2" }
|
||||||
nu-test-support = { path = "../nu-test-support", version = "0.99.2" }
|
nu-test-support = { path = "../nu-test-support", version = "0.99.2" }
|
||||||
rstest = { workspace = true, default-features = false }
|
rstest = { workspace = true, default-features = false }
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
|
test-case = "3.3.1"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
nu-cmd-base = { path = "../nu-cmd-base", version = "0.99.2" }
|
nu-cmd-base = { path = "../nu-cmd-base", version = "0.99.2" }
|
||||||
|
|
|
@ -17,6 +17,7 @@ pub fn add_cli_context(mut engine_state: EngineState) -> EngineState {
|
||||||
CommandlineGetCursor,
|
CommandlineGetCursor,
|
||||||
CommandlineSetCursor,
|
CommandlineSetCursor,
|
||||||
History,
|
History,
|
||||||
|
HistoryImport,
|
||||||
HistorySession,
|
HistorySession,
|
||||||
Keybindings,
|
Keybindings,
|
||||||
KeybindingsDefault,
|
KeybindingsDefault,
|
||||||
|
|
9
crates/nu-cli/src/commands/history/fields.rs
Normal file
9
crates/nu-cli/src/commands/history/fields.rs
Normal file
|
@ -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";
|
|
@ -5,6 +5,8 @@ use reedline::{
|
||||||
SqliteBackedHistory,
|
SqliteBackedHistory,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use super::fields;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct History;
|
pub struct History;
|
||||||
|
|
||||||
|
@ -83,7 +85,8 @@ impl Command for History {
|
||||||
entries.into_iter().enumerate().map(move |(idx, entry)| {
|
entries.into_iter().enumerate().map(move |(idx, entry)| {
|
||||||
Value::record(
|
Value::record(
|
||||||
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),
|
"index" => Value::int(idx as i64, head),
|
||||||
},
|
},
|
||||||
head,
|
head,
|
||||||
|
@ -176,13 +179,13 @@ fn create_history_record(idx: usize, entry: HistoryItem, long: bool, head: Span)
|
||||||
Value::record(
|
Value::record(
|
||||||
record! {
|
record! {
|
||||||
"item_id" => item_id_value,
|
"item_id" => item_id_value,
|
||||||
"start_timestamp" => start_timestamp_value,
|
fields::START_TIMESTAMP => start_timestamp_value,
|
||||||
"command" => command_value,
|
fields::COMMAND_LINE => command_value,
|
||||||
"session_id" => session_id_value,
|
fields::SESSION_ID => session_id_value,
|
||||||
"hostname" => hostname_value,
|
fields::HOSTNAME => hostname_value,
|
||||||
"cwd" => cwd_value,
|
fields::CWD => cwd_value,
|
||||||
"duration" => duration_value,
|
fields::DURATION => duration_value,
|
||||||
"exit_status" => exit_status_value,
|
fields::EXIT_STATUS => exit_status_value,
|
||||||
"idx" => index_value,
|
"idx" => index_value,
|
||||||
},
|
},
|
||||||
head,
|
head,
|
||||||
|
@ -190,11 +193,11 @@ fn create_history_record(idx: usize, entry: HistoryItem, long: bool, head: Span)
|
||||||
} else {
|
} else {
|
||||||
Value::record(
|
Value::record(
|
||||||
record! {
|
record! {
|
||||||
"start_timestamp" => start_timestamp_value,
|
fields::START_TIMESTAMP => start_timestamp_value,
|
||||||
"command" => command_value,
|
fields::COMMAND_LINE => command_value,
|
||||||
"cwd" => cwd_value,
|
fields::CWD => cwd_value,
|
||||||
"duration" => duration_value,
|
fields::DURATION => duration_value,
|
||||||
"exit_status" => exit_status_value,
|
fields::EXIT_STATUS => exit_status_value,
|
||||||
},
|
},
|
||||||
head,
|
head,
|
||||||
)
|
)
|
||||||
|
|
418
crates/nu-cli/src/commands/history/history_import.rs
Normal file
418
crates/nu-cli/src/commands/history/history_import.rs
Normal file
|
@ -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<Example> {
|
||||||
|
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<PipelineData, ShellError> {
|
||||||
|
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<PathBuf>,
|
||||||
|
) -> Result<Box<dyn History>, 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<impl History + 'static, ReedlineError>,
|
||||||
|
) -> Result<Box<dyn History>, ShellError> {
|
||||||
|
result
|
||||||
|
.map(|x| Box::new(x) as Box<dyn History>)
|
||||||
|
.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<Item = Result<HistoryItem, ShellError>>,
|
||||||
|
) -> 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<HistoryItem, ShellError> {
|
||||||
|
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<HistoryItem, ShellError> {
|
||||||
|
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<T>(
|
||||||
|
rec: &mut Record,
|
||||||
|
field: &'static str,
|
||||||
|
f: impl FnOnce(Value) -> Result<T, ShellError>,
|
||||||
|
) -> Result<Option<T>, 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::<Vec<_>>();
|
||||||
|
return Err(ShellError::TypeMismatch {
|
||||||
|
err_message: format!("unsupported column names: {}", cols.join(", ")),
|
||||||
|
span,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn duration_from_value(v: Value) -> Result<std::time::Duration, ShellError> {
|
||||||
|
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<PathBuf, ShellError> {
|
||||||
|
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<Option<PathBuf>, 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());
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,8 @@
|
||||||
|
mod fields;
|
||||||
mod history_;
|
mod history_;
|
||||||
|
mod history_import;
|
||||||
mod history_session;
|
mod history_session;
|
||||||
|
|
||||||
pub use history_::History;
|
pub use history_::History;
|
||||||
|
pub use history_import::HistoryImport;
|
||||||
pub use history_session::HistorySession;
|
pub use history_session::HistorySession;
|
||||||
|
|
|
@ -7,7 +7,7 @@ mod keybindings_list;
|
||||||
mod keybindings_listen;
|
mod keybindings_listen;
|
||||||
|
|
||||||
pub use commandline::{Commandline, CommandlineEdit, CommandlineGetCursor, CommandlineSetCursor};
|
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::Keybindings;
|
||||||
pub use keybindings_default::KeybindingsDefault;
|
pub use keybindings_default::KeybindingsDefault;
|
||||||
pub use keybindings_list::KeybindingsList;
|
pub use keybindings_list::KeybindingsList;
|
||||||
|
|
295
crates/nu-cli/tests/commands/history_import.rs
Normal file
295
crates/nu-cli/tests/commands/history_import.rs
Normal file
|
@ -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<str>) -> 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, ReedlineError> {
|
||||||
|
FileBackedHistory::with_file(
|
||||||
|
100,
|
||||||
|
self.cfg_dir
|
||||||
|
.path()
|
||||||
|
.join("nushell")
|
||||||
|
.join(HistoryFileFormat::Plaintext.default_file_name()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_sqlite(&self) -> Result<SqliteBackedHistory, ReedlineError> {
|
||||||
|
SqliteBackedHistory::with_file(
|
||||||
|
self.cfg_dir
|
||||||
|
.path()
|
||||||
|
.join("nushell")
|
||||||
|
.join(HistoryFileFormat::Sqlite.default_file_name()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_backend(&self, format: HistoryFileFormat) -> Result<Box<dyn History>, ReedlineError> {
|
||||||
|
fn boxed(be: impl History + 'static) -> Box<dyn History> {
|
||||||
|
Box::new(be)
|
||||||
|
}
|
||||||
|
use HistoryFileFormat::*;
|
||||||
|
match format {
|
||||||
|
Plaintext => self.open_plaintext().map(boxed),
|
||||||
|
Sqlite => self.open_sqlite().map(boxed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum HistorySource {
|
||||||
|
Vec(Vec<HistoryItem>),
|
||||||
|
Command(&'static str),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TestCase {
|
||||||
|
dst_format: HistoryFileFormat,
|
||||||
|
dst_history: Vec<HistoryItem>,
|
||||||
|
src_history: HistorySource,
|
||||||
|
want_history: Vec<HistoryItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Vec<HistoryItem>, ReedlineError> {
|
||||||
|
history.search(SearchQuery::everything(
|
||||||
|
reedline::SearchDirection::Forward,
|
||||||
|
None,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_all(history: &mut dyn History, items: Vec<HistoryItem>) -> 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()
|
||||||
|
}
|
|
@ -1,2 +1,3 @@
|
||||||
|
mod history_import;
|
||||||
mod keybindings_list;
|
mod keybindings_list;
|
||||||
mod nu_highlight;
|
mod nu_highlight;
|
||||||
|
|
|
@ -234,7 +234,7 @@ macro_rules! nu_with_plugins {
|
||||||
}
|
}
|
||||||
|
|
||||||
use crate::{Outcome, NATIVE_PATH_ENV_VAR};
|
use crate::{Outcome, NATIVE_PATH_ENV_VAR};
|
||||||
use nu_path::{AbsolutePath, AbsolutePathBuf, Path};
|
use nu_path::{AbsolutePath, AbsolutePathBuf, Path, PathBuf};
|
||||||
use std::{
|
use std::{
|
||||||
ffi::OsStr,
|
ffi::OsStr,
|
||||||
process::{Command, Stdio},
|
process::{Command, Stdio},
|
||||||
|
@ -248,6 +248,10 @@ pub struct NuOpts {
|
||||||
pub envs: Option<Vec<(String, String)>>,
|
pub envs: Option<Vec<(String, String)>>,
|
||||||
pub collapse_output: Option<bool>,
|
pub collapse_output: Option<bool>,
|
||||||
pub use_ir: Option<bool>,
|
pub use_ir: Option<bool>,
|
||||||
|
// 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<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn nu_run_test(opts: NuOpts, commands: impl AsRef<str>, with_std: bool) -> Outcome {
|
pub fn nu_run_test(opts: NuOpts, commands: impl AsRef<str>, with_std: bool) -> Outcome {
|
||||||
|
@ -278,8 +282,14 @@ pub fn nu_run_test(opts: NuOpts, commands: impl AsRef<str>, with_std: bool) -> O
|
||||||
command.envs(envs);
|
command.envs(envs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure that the user's config doesn't interfere with the tests
|
match opts.env_config {
|
||||||
command.arg("--no-config-file");
|
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 {
|
if !with_std {
|
||||||
command.arg("--no-std-lib");
|
command.arg("--no-std-lib");
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue