Support other variables than PATH in pathvar (#3791)

* Fix swapped PATH env var separators

* Support pathvar to manipulate other vars than PATH

* Add tests for pathvar and its subcommands

* Fix PATH env name for Windows

Seems like Windows uses PATH as well.

Co-authored-by: Jakub Žádník <jakub.zadnik@tuni.fi>
This commit is contained in:
Jakub Žádník 2021-07-23 10:11:56 +03:00 committed by GitHub
parent d88d7f26e4
commit f9f39c0a1c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 504 additions and 44 deletions

View File

@ -1,9 +1,10 @@
use super::get_var;
use crate::prelude::*;
use nu_engine::WholeStreamCommand;
use nu_errors::ShellError;
use nu_protocol::{Signature, SyntaxShape};
use nu_source::Tagged;
use nu_test_support::{NATIVE_PATH_ENV_SEPARATOR, NATIVE_PATH_ENV_VAR};
use nu_test_support::NATIVE_PATH_ENV_SEPARATOR;
use std::path::PathBuf;
pub struct SubCommand;
@ -14,7 +15,14 @@ impl WholeStreamCommand for SubCommand {
}
fn signature(&self) -> Signature {
Signature::build("pathvar add").required("path", SyntaxShape::FilePath, "path to add")
Signature::build("pathvar add")
.required("path", SyntaxShape::FilePath, "path to add")
.named(
"var",
SyntaxShape::String,
"Use a different variable than PATH",
Some('v'),
)
}
fn usage(&self) -> &str {
@ -37,17 +45,21 @@ impl WholeStreamCommand for SubCommand {
pub fn add(args: CommandArgs) -> Result<OutputStream, ShellError> {
let ctx = &args.context;
let var = get_var(&args)?;
let path_to_add: Tagged<PathBuf> = args.req(0)?;
let path = path_to_add.item.into_os_string().into_string();
if let Ok(mut path) = path {
path.push(NATIVE_PATH_ENV_SEPARATOR);
if let Some(old_pathvar) = ctx.scope.get_env(NATIVE_PATH_ENV_VAR) {
if let Some(old_pathvar) = ctx.scope.get_env(&var) {
path.push_str(&old_pathvar);
ctx.scope.add_env_var(NATIVE_PATH_ENV_VAR, path);
ctx.scope.add_env_var(&var.item, path);
Ok(OutputStream::empty())
} else {
Err(ShellError::unexpected("PATH not set"))
Err(ShellError::unexpected(&format!(
"Variable {} not set",
&var.item
)))
}
} else {
Err(ShellError::labeled_error(

View File

@ -1,9 +1,10 @@
use super::get_var;
use crate::prelude::*;
use nu_engine::WholeStreamCommand;
use nu_errors::ShellError;
use nu_protocol::{Signature, SyntaxShape};
use nu_source::Tagged;
use nu_test_support::{NATIVE_PATH_ENV_SEPARATOR, NATIVE_PATH_ENV_VAR};
use nu_test_support::NATIVE_PATH_ENV_SEPARATOR;
use std::path::PathBuf;
pub struct SubCommand;
@ -14,7 +15,14 @@ impl WholeStreamCommand for SubCommand {
}
fn signature(&self) -> Signature {
Signature::build("pathvar append").required("path", SyntaxShape::FilePath, "path to append")
Signature::build("pathvar append")
.required("path", SyntaxShape::FilePath, "path to append")
.named(
"var",
SyntaxShape::String,
"Use a different variable than PATH",
Some('v'),
)
}
fn usage(&self) -> &str {
@ -36,17 +44,22 @@ impl WholeStreamCommand for SubCommand {
pub fn add(args: CommandArgs) -> Result<OutputStream, ShellError> {
let ctx = &args.context;
let var = get_var(&args)?;
let path_to_append_arg: Tagged<PathBuf> = args.req(0)?;
let path_to_append = path_to_append_arg.item.into_os_string().into_string();
if let Ok(path) = path_to_append {
if let Some(mut pathvar) = ctx.scope.get_env(NATIVE_PATH_ENV_VAR) {
if let Some(mut pathvar) = ctx.scope.get_env(&var) {
pathvar.push(NATIVE_PATH_ENV_SEPARATOR);
pathvar.push_str(&path);
ctx.scope.add_env_var(NATIVE_PATH_ENV_VAR, pathvar);
ctx.scope.add_env_var(&var.item, pathvar);
Ok(OutputStream::empty())
} else {
Err(ShellError::unexpected("PATH not set"))
Err(ShellError::unexpected(&format!(
"Variable {} not set",
&var.item
)))
}
} else {
Err(ShellError::labeled_error(

View File

@ -1,8 +1,9 @@
use super::get_var;
use crate::prelude::*;
use nu_engine::WholeStreamCommand;
use nu_errors::ShellError;
use nu_protocol::{Signature, Value};
use nu_test_support::{NATIVE_PATH_ENV_SEPARATOR, NATIVE_PATH_ENV_VAR};
use nu_protocol::{Signature, SyntaxShape, Value};
use nu_test_support::NATIVE_PATH_ENV_SEPARATOR;
pub struct Command;
@ -12,11 +13,17 @@ impl WholeStreamCommand for Command {
}
fn signature(&self) -> Signature {
Signature::build("pathvar")
Signature::build("pathvar").named(
"var",
SyntaxShape::String,
"Use a different variable than PATH",
Some('v'),
)
}
fn usage(&self) -> &str {
"Manipulate the PATH variable (or pathvar)."
r#"Manipulate the PATH variable (pathvar) or a different variable following the
same rules."#
}
fn run(&self, args: CommandArgs) -> Result<OutputStream, ShellError> {
@ -30,6 +37,11 @@ impl WholeStreamCommand for Command {
example: "pathvar",
result: None,
},
Example {
description: "Display the current session's LD_LIBRARY_PATH",
example: "pathvar -v LD_LIBRARY_PATH",
result: None,
},
Example {
description: "Add /usr/bin to the pathvar",
example: "pathvar add /usr/bin",
@ -45,7 +57,9 @@ impl WholeStreamCommand for Command {
}
pub fn get_pathvar(args: CommandArgs) -> Result<OutputStream, ShellError> {
if let Some(pathvar) = args.context.scope.get_env(NATIVE_PATH_ENV_VAR) {
let var = get_var(&args)?;
if let Some(pathvar) = args.context.scope.get_env(&var) {
let pathvar: Vec<Value> = pathvar
.split(NATIVE_PATH_ENV_SEPARATOR)
.map(Value::from)
@ -53,6 +67,9 @@ pub fn get_pathvar(args: CommandArgs) -> Result<OutputStream, ShellError> {
Ok(OutputStream::from(pathvar))
} else {
Err(ShellError::unexpected("PATH not set"))
Err(ShellError::unexpected(&format!(
"Variable {} not set",
&var.item
)))
}
}

View File

@ -11,3 +11,15 @@ pub use command::Command as Pathvar;
pub use remove::SubCommand as PathvarRemove;
pub use reset::SubCommand as PathvarReset;
pub use save::SubCommand as PathvarSave;
use nu_engine::CommandArgs;
use nu_errors::ShellError;
use nu_source::{Tagged, TaggedItem};
use nu_test_support::NATIVE_PATH_ENV_VAR;
fn get_var(args: &CommandArgs) -> Result<Tagged<String>, ShellError> {
Ok(args
.get_flag("var")?
.unwrap_or_else(|| String::from(NATIVE_PATH_ENV_VAR))
.tagged_unknown())
}

View File

@ -1,9 +1,10 @@
use super::get_var;
use crate::prelude::*;
use nu_engine::WholeStreamCommand;
use nu_errors::ShellError;
use nu_protocol::{Signature, SyntaxShape};
use nu_source::Tagged;
use nu_test_support::{NATIVE_PATH_ENV_SEPARATOR, NATIVE_PATH_ENV_VAR};
use nu_test_support::NATIVE_PATH_ENV_SEPARATOR;
pub struct SubCommand;
@ -13,11 +14,18 @@ impl WholeStreamCommand for SubCommand {
}
fn signature(&self) -> Signature {
Signature::build("pathvar remove").required(
"index",
SyntaxShape::Int,
"index of the path to remove (starting at 0)",
)
Signature::build("pathvar remove")
.required(
"index",
SyntaxShape::Int,
"index of the path to remove (starting at 0)",
)
.named(
"var",
SyntaxShape::String,
"Use a different variable than PATH",
Some('v'),
)
}
fn usage(&self) -> &str {
@ -39,10 +47,12 @@ impl WholeStreamCommand for SubCommand {
pub fn remove(args: CommandArgs) -> Result<OutputStream, ShellError> {
let ctx = &args.context;
let var = get_var(&args)?;
let index_to_remove_arg: Tagged<u64> = args.req(0)?;
let index_to_remove = index_to_remove_arg.item as usize;
if let Some(old_pathvar) = ctx.scope.get_env(NATIVE_PATH_ENV_VAR) {
if let Some(old_pathvar) = ctx.scope.get_env(&var) {
let mut paths: Vec<&str> = old_pathvar.split(NATIVE_PATH_ENV_SEPARATOR).collect();
if index_to_remove >= paths.len() {
@ -55,12 +65,15 @@ pub fn remove(args: CommandArgs) -> Result<OutputStream, ShellError> {
paths.remove(index_to_remove);
ctx.scope.add_env_var(
NATIVE_PATH_ENV_VAR,
&var.item,
paths.join(&NATIVE_PATH_ENV_SEPARATOR.to_string()),
);
Ok(OutputStream::empty())
} else {
Err(ShellError::unexpected("PATH not set"))
Err(ShellError::unexpected(&format!(
"Variable {} not set",
&var.item
)))
}
}

View File

@ -1,8 +1,9 @@
use super::get_var;
use crate::prelude::*;
use nu_engine::WholeStreamCommand;
use nu_errors::ShellError;
use nu_protocol::{Signature, UntaggedValue};
use nu_test_support::{NATIVE_PATH_ENV_SEPARATOR, NATIVE_PATH_ENV_VAR};
use nu_protocol::{Signature, SyntaxShape, UntaggedValue};
use nu_test_support::NATIVE_PATH_ENV_SEPARATOR;
pub struct SubCommand;
@ -12,7 +13,12 @@ impl WholeStreamCommand for SubCommand {
}
fn signature(&self) -> Signature {
Signature::build("pathvar reset")
Signature::build("pathvar reset").named(
"var",
SyntaxShape::String,
"Use a different variable than PATH",
Some('v'),
)
}
fn usage(&self) -> &str {
@ -23,24 +29,29 @@ impl WholeStreamCommand for SubCommand {
reset(args)
}
}
pub fn reset(args: CommandArgs) -> Result<OutputStream, ShellError> {
let name = args.call_info.name_tag.clone();
let ctx = &args.context;
let var = get_var(&args)?;
let var_lower = var.clone().map(|s| s.to_lowercase());
if let Some(global_cfg) = &mut ctx.configs().lock().global_config {
let default_pathvar = global_cfg.vars.get("path");
let default_pathvar = global_cfg.vars.get(&var_lower.item);
if let Some(pathvar) = default_pathvar {
if let UntaggedValue::Table(paths) = &pathvar.value {
let pathvar_str = paths
.iter()
.map(|x| x.as_string().expect("Error converting path to string"))
.join(&NATIVE_PATH_ENV_SEPARATOR.to_string());
ctx.scope.add_env_var(NATIVE_PATH_ENV_VAR, pathvar_str);
ctx.scope.add_env_var(&var.item, pathvar_str);
}
} else {
return Err(ShellError::untagged_runtime_error(
"Default path is not set in config file.",
));
return Err(ShellError::untagged_runtime_error(&format!(
"Default {} is not set in config file.",
&var_lower.item
)));
}
Ok(OutputStream::empty())
} else {

View File

@ -1,8 +1,9 @@
use super::get_var;
use crate::prelude::*;
use nu_engine::WholeStreamCommand;
use nu_errors::ShellError;
use nu_protocol::{Signature, UntaggedValue, Value};
use nu_test_support::{NATIVE_PATH_ENV_SEPARATOR, NATIVE_PATH_ENV_VAR};
use nu_protocol::{Signature, SyntaxShape, UntaggedValue, Value};
use nu_test_support::NATIVE_PATH_ENV_SEPARATOR;
pub struct SubCommand;
@ -12,7 +13,12 @@ impl WholeStreamCommand for SubCommand {
}
fn signature(&self) -> Signature {
Signature::build("pathvar save")
Signature::build("pathvar save").named(
"var",
SyntaxShape::String,
"Use a different variable than PATH",
Some('v'),
)
}
fn usage(&self) -> &str {
@ -27,8 +33,11 @@ pub fn save(args: CommandArgs) -> Result<OutputStream, ShellError> {
let name = args.call_info.name_tag.clone();
let ctx = &args.context;
let var = get_var(&args)?;
let var_lower = var.clone().map(|s| s.to_lowercase());
if let Some(global_cfg) = &mut ctx.configs().lock().global_config {
if let Some(pathvar) = ctx.scope.get_env(NATIVE_PATH_ENV_VAR) {
if let Some(pathvar) = ctx.scope.get_env(&var) {
let paths: Vec<Value> = pathvar
.split(NATIVE_PATH_ENV_SEPARATOR)
.map(Value::from)
@ -40,13 +49,16 @@ pub fn save(args: CommandArgs) -> Result<OutputStream, ShellError> {
Tag::from(Span::from(&span_range)),
);
global_cfg.vars.insert("path".to_string(), row);
global_cfg.vars.insert(var_lower.item, row);
global_cfg.write()?;
ctx.reload_config(global_cfg)?;
Ok(OutputStream::empty())
} else {
Err(ShellError::unexpected("PATH not set"))
Err(ShellError::unexpected(&format!(
"Variable {} not set",
&var.item
)))
}
} else {
let value = UntaggedValue::Error(crate::commands::config::err_no_global_cfg_present())

View File

@ -37,6 +37,7 @@ mod move_;
mod open;
mod parse;
mod path;
mod pathvar;
mod prepend;
mod random;
mod range;

View File

@ -0,0 +1,372 @@
use nu_test_support::fs::Stub::FileWithContent;
use nu_test_support::fs::{AbsolutePath, DisplayPath};
use nu_test_support::playground::{says, Playground};
use nu_test_support::{nu, NATIVE_PATH_ENV_SEPARATOR};
use std::path::PathBuf;
use hamcrest2::assert_that;
use hamcrest2::prelude::*;
/// Helper function that joins string literals with ':' or ';', based on host OS
fn join_env_sep(pieces: &[&str]) -> String {
let sep_string = String::from(NATIVE_PATH_ENV_SEPARATOR);
pieces.join(&sep_string)
}
// Helpers
#[cfg(windows)]
#[test]
fn joins_env_on_windows() {
let pieces = ["sausage", "bacon", "spam"];
let actual = join_env_sep(&pieces);
assert_eq!(&actual, "sausage;bacon;spam");
}
#[cfg(not(windows))]
#[test]
fn joins_env_on_non_windows() {
let pieces = ["sausage", "bacon", "spam"];
let actual = join_env_sep(&pieces);
assert_eq!(&actual, "sausage:bacon:spam");
}
// pathvar
#[test]
fn pathvar_correctly_reads_path_from_config_and_env() {
Playground::setup("hi_there", |dirs, sandbox| {
let file = AbsolutePath::new(dirs.test().join("config.toml"));
sandbox
.with_files(vec![FileWithContent(
"config.toml",
r#"
skip_welcome_message = true
path = ["/Users/andresrobalino/.volta/bin", "/Users/mosqueteros/bin"]
"#,
)])
.with_config(&file)
.with_env(
nu_test_support::NATIVE_PATH_ENV_VAR,
&PathBuf::from("/Users/mosquito/proboscis").display_path(),
);
let expected =
"/Users/andresrobalino/.volta/bin-/Users/mosqueteros/bin-/Users/mosquito/proboscis";
let actual = sandbox.pipeline(r#" pathvar | str collect '-' "#);
assert_that!(actual, says().stdout(&expected));
})
}
#[test]
fn pathvar_correctly_reads_env_var_from_config_and_env() {
Playground::setup("hi_there", |_, sandbox| {
sandbox.with_env(
"BREAKFAST",
&join_env_sep(&["egg", "sausage", "bacon", "spam"]),
);
let expected = "egg-sausage-bacon-spam";
let actual = sandbox.pipeline(r#" pathvar -v BREAKFAST | str collect '-' "#);
assert_that!(actual, says().stdout(&expected));
})
}
// pathvar add
#[test]
fn pathvar_adds_to_path() {
Playground::setup("hi_there", |_, sandbox| {
sandbox.with_env(
nu_test_support::NATIVE_PATH_ENV_VAR,
&PathBuf::from("/Users/mosquito/proboscis").display_path(),
);
let expected = "spam-/Users/mosquito/proboscis";
let actual = sandbox.pipeline(r#" pathvar add spam; $nu.path | str collect '-' "#);
assert_that!(actual, says().stdout(&expected));
})
}
#[test]
fn pathvar_adds_to_env_var() {
Playground::setup("hi_there", |_, sandbox| {
sandbox.with_env("BREAKFAST", &join_env_sep(&["egg", "sausage", "bacon"]));
let expected = join_env_sep(&["spam", "egg", "sausage", "bacon"]);
let actual = sandbox.pipeline(r#" pathvar add -v BREAKFAST spam; $nu.env.BREAKFAST "#);
assert_that!(actual, says().stdout(&expected));
})
}
// pathvar append
#[test]
fn pathvar_appends_to_path() {
Playground::setup("hi_there", |_, sandbox| {
sandbox.with_env(
nu_test_support::NATIVE_PATH_ENV_VAR,
&PathBuf::from("/Users/mosquito/proboscis").display_path(),
);
let expected = "/Users/mosquito/proboscis-spam";
let actual = sandbox.pipeline(r#" pathvar append spam; $nu.path | str collect '-' "#);
assert_that!(actual, says().stdout(&expected));
})
}
#[test]
fn pathvar_appends_to_env_var() {
Playground::setup("hi_there", |_, sandbox| {
sandbox.with_env("BREAKFAST", &join_env_sep(&["egg", "sausage", "bacon"]));
let expected = join_env_sep(&["egg", "sausage", "bacon", "spam"]);
let actual = sandbox.pipeline(r#" pathvar append -v BREAKFAST spam; $nu.env.BREAKFAST "#);
assert_that!(actual, says().stdout(&expected));
})
}
// pathvar remove
#[test]
fn pathvar_removes_from_path() {
Playground::setup("hi_there", |_, sandbox| {
sandbox.with_env(
nu_test_support::NATIVE_PATH_ENV_VAR,
&join_env_sep(&["/Users/mosquito/proboscis", "spam"]),
);
let expected = "/Users/mosquito/proboscis";
let actual = sandbox.pipeline(r#" pathvar remove 1; $nu.path"#);
assert_that!(actual, says().stdout(&expected));
})
}
#[test]
fn pathvar_removes_from_env_var() {
Playground::setup("hi_there", |_, sandbox| {
sandbox.with_env(
"BREAKFAST",
&join_env_sep(&["egg", "sausage", "bacon", "spam"]),
);
let expected = join_env_sep(&["egg", "sausage", "bacon"]);
let actual = sandbox.pipeline(r#" pathvar remove -v BREAKFAST 3; $nu.env.BREAKFAST "#);
assert_that!(actual, says().stdout(&expected));
})
}
// pathvar reset
#[test]
fn pathvar_resets_path_from_config() {
Playground::setup("hi_there", |dirs, sandbox| {
let file = AbsolutePath::new(dirs.test().join("config.toml"));
sandbox
.with_files(vec![FileWithContent(
"config.toml",
r#"
skip_welcome_message = true
path = ["/Users/andresrobalino/.volta/bin", "/Users/mosqueteros/bin"]
"#,
)])
.with_config(&file)
.with_env(
nu_test_support::NATIVE_PATH_ENV_VAR,
&PathBuf::from("/Users/mosquito/proboscis").display_path(),
);
let expected = "/Users/andresrobalino/.volta/bin-/Users/mosqueteros/bin";
let actual = sandbox.pipeline(
r#"
pathvar reset
pathvar | str collect '-'
"#,
);
assert_that!(actual, says().stdout(&expected));
})
}
#[test]
fn pathvar_resets_env_var_from_config() {
Playground::setup("hi_there", |dirs, sandbox| {
let file = AbsolutePath::new(dirs.test().join("config.toml"));
sandbox
.with_files(vec![FileWithContent(
"config.toml",
r#"
skip_welcome_message = true
breakfast = ["egg", "sausage", "bacon"]
"#,
)])
.with_config(&file)
.with_env(
"BREAKFAST",
&join_env_sep(&["egg", "sausage", "bacon", "spam"]),
);
let expected = "egg-sausage-bacon";
let actual = sandbox.pipeline(
r#"
pathvar reset -v BREAKFAST
pathvar -v BREAKFAST | str collect '-'
"#,
);
assert_that!(actual, says().stdout(&expected));
})
}
// pathvar save
#[test]
fn pathvar_saves_path_to_config() {
Playground::setup("hi_there", |dirs, sandbox| {
let file = AbsolutePath::new(dirs.test().join("config.toml"));
sandbox
.with_files(vec![FileWithContent(
"config.toml",
r#"
skip_welcome_message = true
path = ["/Users/andresrobalino/.volta/bin", "/Users/mosqueteros/bin"]
"#,
)])
.with_config(&file)
.with_env(
nu_test_support::NATIVE_PATH_ENV_VAR,
&PathBuf::from("/Users/mosquito/proboscis").display_path(),
);
let expected =
"/Users/andresrobalino/.volta/bin-/Users/mosqueteros/bin-/Users/mosquito/proboscis";
let actual = sandbox.pipeline(
r#"
pathvar save
$nu.path | str collect '-'
"#,
);
assert_that!(actual, says().stdout(&expected));
})
}
#[test]
fn pathvar_saves_env_var_to_config() {
Playground::setup("hi_there", |dirs, sandbox| {
let file = AbsolutePath::new(dirs.test().join("config.toml"));
sandbox
.with_files(vec![FileWithContent(
"config.toml",
r#"
skip_welcome_message = true
breakfast = ["egg", "sausage", "bacon"]
"#,
)])
.with_config(&file)
.with_env("BREAKFAST", "spam");
let expected = "spam";
let actual = sandbox.pipeline(
r#"
pathvar save -v BREAKFAST
(config).breakfast | str collect '-'
"#,
);
assert_that!(actual, says().stdout(&expected));
})
}
#[test]
fn pathvar_saves_new_path_to_config() {
Playground::setup("hi_there", |dirs, sandbox| {
let file = AbsolutePath::new(dirs.test().join("config.toml"));
sandbox
.with_files(vec![FileWithContent(
"config.toml",
r#"
skip_welcome_message = true
"#,
)])
.with_config(&file)
.with_env(
nu_test_support::NATIVE_PATH_ENV_VAR,
&PathBuf::from("/Users/mosquito/proboscis").display_path(),
);
let expected = "/Users/mosquito/proboscis";
let actual = sandbox.pipeline(
r#"
pathvar save
$nu.path | str collect '-'
"#,
);
assert_that!(actual, says().stdout(&expected));
})
}
#[test]
fn pathvar_saves_new_env_var_to_config() {
Playground::setup("hi_there", |dirs, sandbox| {
let file = AbsolutePath::new(dirs.test().join("config.toml"));
sandbox
.with_files(vec![FileWithContent(
"config.toml",
r#"
skip_welcome_message = true
"#,
)])
.with_config(&file)
.with_env("BREAKFAST", "spam");
let expected = "spam";
let actual = sandbox.pipeline(
r#"
pathvar save -v BREAKFAST
(config).breakfast | str collect '-'
"#,
);
assert_that!(actual, says().stdout(&expected));
})
}
// test some errors
#[test]
fn pathvar_error_non_existent_env_var() {
Playground::setup("hi_there", |dirs, _| {
let actual = nu!(
cwd: dirs.test(),
"pathvar -v EGGS_BACON_SPAM_SAUSAGE_SPAM_AND_SPAM_WITH_EXTRA_SPAM"
);
assert!(actual.err.contains("Error"));
assert!(actual.err.contains("not set"));
})
}

View File

@ -9,15 +9,12 @@ pub struct Outcome {
pub err: String,
}
#[cfg(windows)]
pub const NATIVE_PATH_ENV_VAR: &str = "Path";
#[cfg(not(windows))]
pub const NATIVE_PATH_ENV_VAR: &str = "PATH";
#[cfg(windows)]
pub const NATIVE_PATH_ENV_SEPARATOR: char = ':';
#[cfg(not(windows))]
pub const NATIVE_PATH_ENV_SEPARATOR: char = ';';
#[cfg(not(windows))]
pub const NATIVE_PATH_ENV_SEPARATOR: char = ':';
impl Outcome {
pub fn new(out: String, err: String) -> Outcome {