diff --git a/src/rust/engine/options/src/args.rs b/src/rust/engine/options/src/args.rs index 6457c635efc..9c980d0a439 100644 --- a/src/rust/engine/options/src/args.rs +++ b/src/rust/engine/options/src/args.rs @@ -5,7 +5,8 @@ use std::env; use super::id::{is_valid_scope_name, NameTransform, OptionId, Scope}; use super::{DictEdit, OptionsSource}; -use crate::parse::{expand, expand_to_dict, expand_to_list, ParseError, Parseable}; +use crate::fromfile::{expand, expand_to_dict, expand_to_list}; +use crate::parse::{ParseError, Parseable}; use crate::ListEdit; use core::iter::once; use itertools::{chain, Itertools}; diff --git a/src/rust/engine/options/src/args_tests.rs b/src/rust/engine/options/src/args_tests.rs index 2dd49237474..aef89bda050 100644 --- a/src/rust/engine/options/src/args_tests.rs +++ b/src/rust/engine/options/src/args_tests.rs @@ -5,7 +5,7 @@ use core::fmt::Debug; use maplit::hashmap; use crate::args::Args; -use crate::parse::test_util::write_fromfile; +use crate::fromfile::test_util::write_fromfile; use crate::{option_id, DictEdit, DictEditAction, Val}; use crate::{ListEdit, ListEditAction, OptionId, OptionsSource}; diff --git a/src/rust/engine/options/src/config.rs b/src/rust/engine/options/src/config.rs index 0e5c0800de0..a3705e5317a 100644 --- a/src/rust/engine/options/src/config.rs +++ b/src/rust/engine/options/src/config.rs @@ -10,9 +10,10 @@ use regex::Regex; use toml::value::Table; use toml::Value; -use super::id::{NameTransform, OptionId}; -use super::parse::{expand, expand_to_dict, expand_to_list, Parseable}; use super::{DictEdit, DictEditAction, ListEdit, ListEditAction, OptionsSource, Val}; +use crate::fromfile::{expand, expand_to_dict, expand_to_list}; +use crate::id::{NameTransform, OptionId}; +use crate::parse::Parseable; type InterpolationMap = HashMap; diff --git a/src/rust/engine/options/src/config_tests.rs b/src/rust/engine/options/src/config_tests.rs index 6362dd79409..20b106a95fb 100644 --- a/src/rust/engine/options/src/config_tests.rs +++ b/src/rust/engine/options/src/config_tests.rs @@ -14,7 +14,7 @@ use crate::{ }; use crate::config::Config; -use crate::parse::test_util::write_fromfile; +use crate::fromfile::test_util::write_fromfile; use tempfile::TempDir; fn maybe_config(file_content: &str) -> Result { diff --git a/src/rust/engine/options/src/env.rs b/src/rust/engine/options/src/env.rs index ac12db1156b..646e5a846d7 100644 --- a/src/rust/engine/options/src/env.rs +++ b/src/rust/engine/options/src/env.rs @@ -7,7 +7,8 @@ use std::ffi::OsString; use super::id::{NameTransform, OptionId, Scope}; use super::{DictEdit, OptionsSource}; -use crate::parse::{expand, expand_to_dict, expand_to_list, Parseable}; +use crate::fromfile::{expand, expand_to_dict, expand_to_list}; +use crate::parse::Parseable; use crate::ListEdit; #[derive(Debug)] diff --git a/src/rust/engine/options/src/env_tests.rs b/src/rust/engine/options/src/env_tests.rs index a1d5e84b8b2..9c480ec24fa 100644 --- a/src/rust/engine/options/src/env_tests.rs +++ b/src/rust/engine/options/src/env_tests.rs @@ -2,7 +2,7 @@ // Licensed under the Apache License, Version 2.0 (see LICENSE). use crate::env::Env; -use crate::parse::test_util::write_fromfile; +use crate::fromfile::test_util::write_fromfile; use crate::{option_id, DictEdit, DictEditAction}; use crate::{ListEdit, ListEditAction, OptionId, OptionsSource, Val}; use maplit::hashmap; diff --git a/src/rust/engine/options/src/fromfile.rs b/src/rust/engine/options/src/fromfile.rs new file mode 100644 index 00000000000..022e04d504c --- /dev/null +++ b/src/rust/engine/options/src/fromfile.rs @@ -0,0 +1,139 @@ +// Copyright 2024 Pants project contributors (see CONTRIBUTORS.md). +// Licensed under the Apache License, Version 2.0 (see LICENSE). + +use super::{DictEdit, DictEditAction, ListEdit, ListEditAction}; + +use crate::parse::{mk_parse_err, parse_dict, ParseError, Parseable}; +use log::warn; +use serde::de::Deserialize; +use std::path::{Path, PathBuf}; +use std::{fs, io}; + +// If the corresponding unexpanded value points to a @fromfile, then the +// first component is the path to that file, and the second is the value from the file, +// or None if the file doesn't exist and the @?fromfile syntax was used. +// +// Otherwise, the first component is None and the second is the original value. +type ExpandedValue = (Option, Option); + +fn maybe_expand(value: String) -> Result { + if let Some(suffix) = value.strip_prefix('@') { + if suffix.starts_with('@') { + // @@ escapes the initial @. + Ok((None, Some(suffix.to_owned()))) + } else { + match suffix.strip_prefix('?') { + Some(subsuffix) => { + // @? means the path is allowed to not exist. + let path = PathBuf::from(subsuffix); + match fs::read_to_string(&path) { + Ok(content) => Ok((Some(path), Some(content))), + Err(err) if err.kind() == io::ErrorKind::NotFound => { + warn!("Optional file config '{}' does not exist.", path.display()); + Ok((Some(path), None)) + } + Err(err) => Err(mk_parse_err(err, &path)), + } + } + _ => { + let path = PathBuf::from(suffix); + let content = fs::read_to_string(&path).map_err(|e| mk_parse_err(e, &path))?; + Ok((Some(path), Some(content))) + } + } + } + } else { + Ok((None, Some(value))) + } +} + +pub(crate) fn expand(value: String) -> Result, ParseError> { + let (_, expanded_value) = maybe_expand(value)?; + Ok(expanded_value) +} + +#[derive(Debug)] +enum FromfileType { + Json, + Yaml, + Unknown, +} + +impl FromfileType { + fn detect(path: &Path) -> FromfileType { + if let Some(ext) = path.extension() { + if ext == "json" { + return FromfileType::Json; + } else if ext == "yml" || ext == "yaml" { + return FromfileType::Yaml; + }; + } + FromfileType::Unknown + } +} + +fn try_deserialize<'a, DE: Deserialize<'a>>( + value: &'a str, + path_opt: Option, +) -> Result, ParseError> { + if let Some(path) = path_opt { + match FromfileType::detect(&path) { + FromfileType::Json => serde_json::from_str(value).map_err(|e| mk_parse_err(e, &path)), + FromfileType::Yaml => serde_yaml::from_str(value).map_err(|e| mk_parse_err(e, &path)), + _ => Ok(None), + } + } else { + Ok(None) + } +} + +pub(crate) fn expand_to_list( + value: String, +) -> Result>>, ParseError> { + let (path_opt, value_opt) = maybe_expand(value)?; + if let Some(value) = value_opt { + if let Some(items) = try_deserialize(&value, path_opt)? { + Ok(Some(vec![ListEdit { + action: ListEditAction::Replace, + items, + }])) + } else { + T::parse_list(&value).map(Some) + } + } else { + Ok(None) + } +} + +pub(crate) fn expand_to_dict(value: String) -> Result>, ParseError> { + let (path_opt, value_opt) = maybe_expand(value)?; + if let Some(value) = value_opt { + if let Some(items) = try_deserialize(&value, path_opt)? { + Ok(Some(vec![DictEdit { + action: DictEditAction::Replace, + items, + }])) + } else { + parse_dict(&value).map(|x| Some(vec![x])) + } + } else { + Ok(None) + } +} + +#[cfg(test)] +pub(crate) mod test_util { + use std::fs::File; + use std::io::Write; + use std::path::PathBuf; + use tempfile::{tempdir, TempDir}; + + pub(crate) fn write_fromfile(filename: &str, content: &str) -> (TempDir, PathBuf) { + let tmpdir = tempdir().unwrap(); + let fromfile_path = tmpdir.path().join(filename); + let mut fromfile = File::create(&fromfile_path).unwrap(); + fromfile.write_all(content.as_bytes()).unwrap(); + fromfile.flush().unwrap(); + (tmpdir, fromfile_path) + } +} diff --git a/src/rust/engine/options/src/fromfile_tests.rs b/src/rust/engine/options/src/fromfile_tests.rs new file mode 100644 index 00000000000..f56a45b2ccb --- /dev/null +++ b/src/rust/engine/options/src/fromfile_tests.rs @@ -0,0 +1,294 @@ +// Copyright 2024 Pants project contributors (see CONTRIBUTORS.md). +// Licensed under the Apache License, Version 2.0 (see LICENSE). + +use crate::fromfile::test_util::write_fromfile; +use crate::fromfile::*; +use crate::parse::{ParseError, Parseable}; +use crate::{DictEdit, DictEditAction, ListEdit, ListEditAction, Val}; +use maplit::hashmap; +use std::collections::HashMap; +use std::fmt::Debug; + +macro_rules! check_err { + ($res:expr, $expected_suffix:expr $(,)?) => { + let actual_msg = $res.unwrap_err().render("XXX"); + assert!( + actual_msg.ends_with($expected_suffix), + "Error message does not have expected suffix:\n{actual_msg}\nvs\n{:>width$}", + $expected_suffix, + width = actual_msg.len(), + ) + }; +} + +#[test] +fn test_expand_fromfile() { + let (_tmpdir, fromfile_pathbuf) = write_fromfile("fromfile.txt", "FOO"); + let fromfile_path_str = format!("{}", fromfile_pathbuf.display()); + assert_eq!( + Ok(Some(fromfile_path_str.clone())), + expand(fromfile_path_str.clone()) + ); + assert_eq!( + Ok(Some("FOO".to_string())), + expand(format!("@{}", fromfile_path_str)) + ); + assert_eq!(Ok(None), expand("@?/does/not/exist".to_string())); + let err = expand("@/does/not/exist".to_string()).unwrap_err(); + assert!(err + .render("XXX") + .starts_with("Problem reading /does/not/exist for XXX: No such file or directory")) +} + +#[test] +fn test_expand_fromfile_to_list() { + fn expand_fromfile( + content: &str, + prefix: &str, + filename: &str, + ) -> Result>>, ParseError> { + let (_tmpdir, _) = write_fromfile(filename, content); + expand_to_list(format!( + "{prefix}{}", + _tmpdir.path().join(filename).display() + )) + } + + fn do_test( + content: &str, + expected: &[ListEdit], + filename: &str, + ) { + let res = expand_fromfile(content, "@", filename); + assert_eq!(expected.to_vec(), res.unwrap().unwrap()); + } + + fn add(items: Vec) -> ListEdit { + return ListEdit { + action: ListEditAction::Add, + items: items, + }; + } + + fn remove(items: Vec) -> ListEdit { + return ListEdit { + action: ListEditAction::Remove, + items: items, + }; + } + + fn replace(items: Vec) -> ListEdit { + return ListEdit { + action: ListEditAction::Replace, + items: items, + }; + } + + do_test( + "EXPANDED", + &[add(vec!["EXPANDED".to_string()])], + "fromfile.txt", + ); + do_test( + "['FOO', 'BAR']", + &[replace(vec!["FOO".to_string(), "BAR".to_string()])], + "fromfile.txt", + ); + do_test( + "+['FOO', 'BAR'],-['BAZ']", + &[ + add(vec!["FOO".to_string(), "BAR".to_string()]), + remove(vec!["BAZ".to_string()]), + ], + "fromfile.txt", + ); + do_test( + "[\"FOO\", \"BAR\"]", + &[replace(vec!["FOO".to_string(), "BAR".to_string()])], + "fromfile.json", + ); + do_test( + "- FOO\n- BAR\n", + &[replace(vec!["FOO".to_string(), "BAR".to_string()])], + "fromfile.yaml", + ); + + do_test("true", &[add(vec![true])], "fromfile.txt"); + do_test( + "[true, false]", + &[replace(vec![true, false])], + "fromfile.json", + ); + do_test( + "- true\n- false\n", + &[replace(vec![true, false])], + "fromfile.yaml", + ); + + do_test("-42", &[add(vec![-42])], "fromfile.txt"); + do_test("[10, 12]", &[replace(vec![10, 12])], "fromfile.json"); + do_test("- 22\n- 44\n", &[replace(vec![22, 44])], "fromfile.yaml"); + + do_test("-5.6", &[add(vec![-5.6])], "fromfile.txt"); + do_test("-[3.14]", &[remove(vec![3.14])], "fromfile.txt"); + do_test("[3.14]", &[replace(vec![3.14])], "fromfile.json"); + do_test( + "- 11.22\n- 33.44\n", + &[replace(vec![11.22, 33.44])], + "fromfile.yaml", + ); + + check_err!( + expand_fromfile::("THIS IS NOT JSON", "@", "invalid.json"), + "expected value at line 1 column 1", + ); + + check_err!( + expand_fromfile::("{}", "@", "wrong_type.json"), + "invalid type: map, expected a sequence at line 1 column 0", + ); + + check_err!( + expand_fromfile::("[1, \"FOO\"]", "@", "wrong_type.json"), + "invalid type: string \"FOO\", expected i64 at line 1 column 9", + ); + + check_err!( + expand_fromfile::("THIS IS NOT YAML", "@", "invalid.yml"), + "invalid type: string \"THIS IS NOT YAML\", expected a sequence", + ); + + check_err!( + expand_fromfile::("- 1\n- true", "@", "wrong_type.yaml"), + "invalid type: boolean `true`, expected i64 at line 2 column 3", + ); + + check_err!( + expand_to_list::("@/does/not/exist".to_string()), + "Problem reading /does/not/exist for XXX: No such file or directory (os error 2)", + ); + + assert_eq!( + Ok(None), + expand_to_list::("@?/does/not/exist".to_string()) + ); + + // Test an optional fromfile that does exist, to ensure we handle the `?` in this case. + let res = expand_fromfile::("[1, 2]", "@?", "fromfile.json"); + assert_eq!(vec![replace(vec![1, 2])], res.unwrap().unwrap()); +} + +#[test] +fn test_expand_fromfile_to_dict() { + fn expand_fromfile( + content: &str, + prefix: &str, + filename: &str, + ) -> Result, ParseError> { + let (_tmpdir, _) = write_fromfile(filename, content); + expand_to_dict(format!( + "{prefix}{}", + _tmpdir.path().join(filename).display() + )) + .map(|x| { + if let Some(des) = x { + des.into_iter().next() + } else { + None + } + }) + } + + fn do_test(content: &str, expected: &DictEdit, filename: &str) { + let res = expand_fromfile(content, "@", filename); + assert_eq!(*expected, res.unwrap().unwrap()) + } + + fn add(items: HashMap) -> DictEdit { + return DictEdit { + action: DictEditAction::Add, + items, + }; + } + + fn replace(items: HashMap) -> DictEdit { + return DictEdit { + action: DictEditAction::Replace, + items, + }; + } + + do_test( + "{'FOO': 42}", + &replace(hashmap! {"FOO".to_string() => Val::Int(42),}), + "fromfile.txt", + ); + + do_test( + "+{'FOO': [True, False]}", + &add(hashmap! {"FOO".to_string() => Val::List(vec![Val::Bool(true), Val::Bool(false)]),}), + "fromfile.txt", + ); + + let complex_obj = replace(hashmap! { + "FOO".to_string() => Val::Dict(hashmap! { + "BAR".to_string() => Val::Float(3.14), + "BAZ".to_string() => Val::Dict(hashmap! { + "QUX".to_string() => Val::Bool(true), + "QUUX".to_string() => Val::List(vec![ Val::Int(1), Val::Int(2)]) + }) + }),}); + + do_test( + "{\"FOO\": {\"BAR\": 3.14, \"BAZ\": {\"QUX\": true, \"QUUX\": [1, 2]}}}", + &complex_obj, + "fromfile.json", + ); + do_test( + r#" + FOO: + BAR: 3.14 + BAZ: + QUX: true + QUUX: + - 1 + - 2 + "#, + &complex_obj, + "fromfile.yaml", + ); + + check_err!( + expand_fromfile("THIS IS NOT JSON", "@", "invalid.json"), + "expected value at line 1 column 1", + ); + + check_err!( + expand_fromfile("[1, 2]", "@", "wrong_type.json"), + "invalid type: sequence, expected a map at line 1 column 0", + ); + + check_err!( + expand_fromfile("THIS IS NOT YAML", "@", "invalid.yml"), + "invalid type: string \"THIS IS NOT YAML\", expected a map", + ); + + check_err!( + expand_fromfile("- 1\n- 2", "@", "wrong_type.yaml"), + "invalid type: sequence, expected a map", + ); + + check_err!( + expand_to_dict("@/does/not/exist".to_string()), + "Problem reading /does/not/exist for XXX: No such file or directory (os error 2)", + ); + + assert_eq!(Ok(None), expand_to_dict("@?/does/not/exist".to_string())); + + // Test an optional fromfile that does exist, to ensure we handle the `?` in this case. + let res = expand_fromfile("{'FOO': 42}", "@?", "fromfile.txt"); + assert_eq!( + replace(hashmap! {"FOO".to_string() => Val::Int(42),}), + res.unwrap().unwrap() + ); +} diff --git a/src/rust/engine/options/src/lib.rs b/src/rust/engine/options/src/lib.rs index 801a0a14aff..53c0fe8faa8 100644 --- a/src/rust/engine/options/src/lib.rs +++ b/src/rust/engine/options/src/lib.rs @@ -17,6 +17,10 @@ mod env; #[cfg(test)] mod env_tests; +mod fromfile; +#[cfg(test)] +mod fromfile_tests; + mod id; #[cfg(test)] mod id_tests; diff --git a/src/rust/engine/options/src/parse.rs b/src/rust/engine/options/src/parse.rs index 8540fbba70c..bbccb6c796e 100644 --- a/src/rust/engine/options/src/parse.rs +++ b/src/rust/engine/options/src/parse.rs @@ -4,12 +4,10 @@ use super::{DictEdit, DictEditAction, ListEdit, ListEditAction, Val}; use crate::render_choice; -use log::warn; -use serde::de::{Deserialize, DeserializeOwned}; +use serde::de::DeserializeOwned; use std::collections::HashMap; use std::fmt::Display; -use std::path::{Path, PathBuf}; -use std::{fs, io}; +use std::path::Path; peg::parser! { grammar option_value_parser() for str { @@ -236,6 +234,13 @@ mod err { pub(crate) use err::ParseError; +pub(crate) fn mk_parse_err(err: impl Display, path: &Path) -> ParseError { + ParseError::new(format!( + "Problem reading {path} for {{name}}: {err}", + path = path.display() + )) +} + fn format_parse_error( type_id: &str, value: &str, @@ -331,139 +336,3 @@ impl Parseable for String { .map_err(|e| format_parse_error("string list", value, e)) } } - -// If the corresponding unexpanded value points to a @fromfile, then the -// first component is the path to that file, and the second is the value from the file, -// or None if the file doesn't exist and the @?fromfile syntax was used. -// -// Otherwise, the first component is None and the second is the original value. -type ExpandedValue = (Option, Option); - -fn mk_parse_err(err: impl Display, path: &Path) -> ParseError { - ParseError::new(format!( - "Problem reading {path} for {{name}}: {err}", - path = path.display() - )) -} - -fn maybe_expand(value: String) -> Result { - if let Some(suffix) = value.strip_prefix('@') { - if suffix.starts_with('@') { - // @@ escapes the initial @. - Ok((None, Some(suffix.to_owned()))) - } else { - match suffix.strip_prefix('?') { - Some(subsuffix) => { - // @? means the path is allowed to not exist. - let path = PathBuf::from(subsuffix); - match fs::read_to_string(&path) { - Ok(content) => Ok((Some(path), Some(content))), - Err(err) if err.kind() == io::ErrorKind::NotFound => { - warn!("Optional file config '{}' does not exist.", path.display()); - Ok((Some(path), None)) - } - Err(err) => Err(mk_parse_err(err, &path)), - } - } - _ => { - let path = PathBuf::from(suffix); - let content = fs::read_to_string(&path).map_err(|e| mk_parse_err(e, &path))?; - Ok((Some(path), Some(content))) - } - } - } - } else { - Ok((None, Some(value))) - } -} - -pub(crate) fn expand(value: String) -> Result, ParseError> { - let (_, expanded_value) = maybe_expand(value)?; - Ok(expanded_value) -} - -#[derive(Debug)] -enum FromfileType { - Json, - Yaml, - Unknown, -} - -impl FromfileType { - fn detect(path: &Path) -> FromfileType { - if let Some(ext) = path.extension() { - if ext == "json" { - return FromfileType::Json; - } else if ext == "yml" || ext == "yaml" { - return FromfileType::Yaml; - }; - } - FromfileType::Unknown - } -} - -fn try_deserialize<'a, DE: Deserialize<'a>>( - value: &'a str, - path_opt: Option, -) -> Result, ParseError> { - if let Some(path) = path_opt { - match FromfileType::detect(&path) { - FromfileType::Json => serde_json::from_str(value).map_err(|e| mk_parse_err(e, &path)), - FromfileType::Yaml => serde_yaml::from_str(value).map_err(|e| mk_parse_err(e, &path)), - _ => Ok(None), - } - } else { - Ok(None) - } -} - -pub(crate) fn expand_to_list( - value: String, -) -> Result>>, ParseError> { - let (path_opt, value_opt) = maybe_expand(value)?; - if let Some(value) = value_opt { - if let Some(items) = try_deserialize(&value, path_opt)? { - Ok(Some(vec![ListEdit { - action: ListEditAction::Replace, - items, - }])) - } else { - T::parse_list(&value).map(Some) - } - } else { - Ok(None) - } -} - -pub(crate) fn expand_to_dict(value: String) -> Result>, ParseError> { - let (path_opt, value_opt) = maybe_expand(value)?; - if let Some(value) = value_opt { - if let Some(items) = try_deserialize(&value, path_opt)? { - Ok(Some(vec![DictEdit { - action: DictEditAction::Replace, - items, - }])) - } else { - parse_dict(&value).map(|x| Some(vec![x])) - } - } else { - Ok(None) - } -} - -#[cfg(test)] -pub(crate) mod test_util { - use std::fs::File; - use std::io::Write; - use std::path::PathBuf; - use tempfile::{tempdir, TempDir}; - - pub(crate) fn write_fromfile(filename: &str, content: &str) -> (TempDir, PathBuf) { - let tmpdir = tempdir().unwrap(); - let fromfile_path = tmpdir.path().join(filename); - let mut fromfile = File::create(&fromfile_path).unwrap(); - fromfile.write_all(content.as_bytes()).unwrap(); - fromfile.flush().unwrap(); - (tmpdir, fromfile_path) - } -} diff --git a/src/rust/engine/options/src/parse_tests.rs b/src/rust/engine/options/src/parse_tests.rs index 8a569d58f47..73590ac3fea 100644 --- a/src/rust/engine/options/src/parse_tests.rs +++ b/src/rust/engine/options/src/parse_tests.rs @@ -1,10 +1,8 @@ // Copyright 2021 Pants project contributors (see CONTRIBUTORS.md). // Licensed under the Apache License, Version 2.0 (see LICENSE). -use crate::parse::test_util::write_fromfile; use crate::parse::*; use crate::{DictEdit, DictEditAction, ListEdit, ListEditAction, Val}; -use maplit::hashmap; use std::collections::HashMap; use std::fmt::Debug; @@ -19,18 +17,6 @@ macro_rules! check { ($left:expr, $right:expr, $($arg:tt)+) => { check_with_arg($left, $right, $($arg)+); }; } -macro_rules! check_err { - ($res:expr, $expected_suffix:expr $(,)?) => { - let actual_msg = $res.unwrap_err().render("XXX"); - assert!( - actual_msg.ends_with($expected_suffix), - "Error message does not have expected suffix:\n{actual_msg}\nvs\n{:>width$}", - $expected_suffix, - width = actual_msg.len(), - ) - }; -} - fn check(expected: T, res: Result) { match res { Ok(actual) => assert_eq!(expected, actual), @@ -673,275 +659,3 @@ fn test_parse_heterogeneous_dict() { ) ); } - -#[test] -fn test_expand_fromfile() { - let (_tmpdir, fromfile_pathbuf) = write_fromfile("fromfile.txt", "FOO"); - let fromfile_path_str = format!("{}", fromfile_pathbuf.display()); - assert_eq!( - Ok(Some(fromfile_path_str.clone())), - expand(fromfile_path_str.clone()) - ); - assert_eq!( - Ok(Some("FOO".to_string())), - expand(format!("@{}", fromfile_path_str)) - ); - assert_eq!(Ok(None), expand("@?/does/not/exist".to_string())); - let err = expand("@/does/not/exist".to_string()).unwrap_err(); - assert!(err - .render("XXX") - .starts_with("Problem reading /does/not/exist for XXX: No such file or directory")) -} - -#[test] -fn test_expand_fromfile_to_list() { - fn expand_fromfile( - content: &str, - prefix: &str, - filename: &str, - ) -> Result>>, ParseError> { - let (_tmpdir, _) = write_fromfile(filename, content); - expand_to_list(format!( - "{prefix}{}", - _tmpdir.path().join(filename).display() - )) - } - - fn do_test( - content: &str, - expected: &[ListEdit], - filename: &str, - ) { - let res = expand_fromfile(content, "@", filename); - assert_eq!(expected.to_vec(), res.unwrap().unwrap()); - } - - fn add(items: Vec) -> ListEdit { - return ListEdit { - action: ListEditAction::Add, - items: items, - }; - } - - fn remove(items: Vec) -> ListEdit { - return ListEdit { - action: ListEditAction::Remove, - items: items, - }; - } - - fn replace(items: Vec) -> ListEdit { - return ListEdit { - action: ListEditAction::Replace, - items: items, - }; - } - - do_test( - "EXPANDED", - &[add(vec!["EXPANDED".to_string()])], - "fromfile.txt", - ); - do_test( - "['FOO', 'BAR']", - &[replace(vec!["FOO".to_string(), "BAR".to_string()])], - "fromfile.txt", - ); - do_test( - "+['FOO', 'BAR'],-['BAZ']", - &[ - add(vec!["FOO".to_string(), "BAR".to_string()]), - remove(vec!["BAZ".to_string()]), - ], - "fromfile.txt", - ); - do_test( - "[\"FOO\", \"BAR\"]", - &[replace(vec!["FOO".to_string(), "BAR".to_string()])], - "fromfile.json", - ); - do_test( - "- FOO\n- BAR\n", - &[replace(vec!["FOO".to_string(), "BAR".to_string()])], - "fromfile.yaml", - ); - - do_test("true", &[add(vec![true])], "fromfile.txt"); - do_test( - "[true, false]", - &[replace(vec![true, false])], - "fromfile.json", - ); - do_test( - "- true\n- false\n", - &[replace(vec![true, false])], - "fromfile.yaml", - ); - - do_test("-42", &[add(vec![-42])], "fromfile.txt"); - do_test("[10, 12]", &[replace(vec![10, 12])], "fromfile.json"); - do_test("- 22\n- 44\n", &[replace(vec![22, 44])], "fromfile.yaml"); - - do_test("-5.6", &[add(vec![-5.6])], "fromfile.txt"); - do_test("-[3.14]", &[remove(vec![3.14])], "fromfile.txt"); - do_test("[3.14]", &[replace(vec![3.14])], "fromfile.json"); - do_test( - "- 11.22\n- 33.44\n", - &[replace(vec![11.22, 33.44])], - "fromfile.yaml", - ); - - check_err!( - expand_fromfile::("THIS IS NOT JSON", "@", "invalid.json"), - "expected value at line 1 column 1", - ); - - check_err!( - expand_fromfile::("{}", "@", "wrong_type.json"), - "invalid type: map, expected a sequence at line 1 column 0", - ); - - check_err!( - expand_fromfile::("[1, \"FOO\"]", "@", "wrong_type.json"), - "invalid type: string \"FOO\", expected i64 at line 1 column 9", - ); - - check_err!( - expand_fromfile::("THIS IS NOT YAML", "@", "invalid.yml"), - "invalid type: string \"THIS IS NOT YAML\", expected a sequence", - ); - - check_err!( - expand_fromfile::("- 1\n- true", "@", "wrong_type.yaml"), - "invalid type: boolean `true`, expected i64 at line 2 column 3", - ); - - check_err!( - expand_to_list::("@/does/not/exist".to_string()), - "Problem reading /does/not/exist for XXX: No such file or directory (os error 2)", - ); - - assert_eq!( - Ok(None), - expand_to_list::("@?/does/not/exist".to_string()) - ); - - // Test an optional fromfile that does exist, to ensure we handle the `?` in this case. - let res = expand_fromfile::("[1, 2]", "@?", "fromfile.json"); - assert_eq!(vec![replace(vec![1, 2])], res.unwrap().unwrap()); -} - -#[test] -fn test_expand_fromfile_to_dict() { - fn expand_fromfile( - content: &str, - prefix: &str, - filename: &str, - ) -> Result, ParseError> { - let (_tmpdir, _) = write_fromfile(filename, content); - expand_to_dict(format!( - "{prefix}{}", - _tmpdir.path().join(filename).display() - )) - .map(|x| { - if let Some(des) = x { - des.into_iter().next() - } else { - None - } - }) - } - - fn do_test(content: &str, expected: &DictEdit, filename: &str) { - let res = expand_fromfile(content, "@", filename); - assert_eq!(*expected, res.unwrap().unwrap()) - } - - fn add(items: HashMap) -> DictEdit { - return DictEdit { - action: DictEditAction::Add, - items, - }; - } - - fn replace(items: HashMap) -> DictEdit { - return DictEdit { - action: DictEditAction::Replace, - items, - }; - } - - do_test( - "{'FOO': 42}", - &replace(hashmap! {"FOO".to_string() => Val::Int(42),}), - "fromfile.txt", - ); - - do_test( - "+{'FOO': [True, False]}", - &add(hashmap! {"FOO".to_string() => Val::List(vec![Val::Bool(true), Val::Bool(false)]),}), - "fromfile.txt", - ); - - let complex_obj = replace(hashmap! { - "FOO".to_string() => Val::Dict(hashmap! { - "BAR".to_string() => Val::Float(3.14), - "BAZ".to_string() => Val::Dict(hashmap! { - "QUX".to_string() => Val::Bool(true), - "QUUX".to_string() => Val::List(vec![ Val::Int(1), Val::Int(2)]) - }) - }),}); - - do_test( - "{\"FOO\": {\"BAR\": 3.14, \"BAZ\": {\"QUX\": true, \"QUUX\": [1, 2]}}}", - &complex_obj, - "fromfile.json", - ); - do_test( - r#" - FOO: - BAR: 3.14 - BAZ: - QUX: true - QUUX: - - 1 - - 2 - "#, - &complex_obj, - "fromfile.yaml", - ); - - check_err!( - expand_fromfile("THIS IS NOT JSON", "@", "invalid.json"), - "expected value at line 1 column 1", - ); - - check_err!( - expand_fromfile("[1, 2]", "@", "wrong_type.json"), - "invalid type: sequence, expected a map at line 1 column 0", - ); - - check_err!( - expand_fromfile("THIS IS NOT YAML", "@", "invalid.yml"), - "invalid type: string \"THIS IS NOT YAML\", expected a map", - ); - - check_err!( - expand_fromfile("- 1\n- 2", "@", "wrong_type.yaml"), - "invalid type: sequence, expected a map", - ); - - check_err!( - expand_to_dict("@/does/not/exist".to_string()), - "Problem reading /does/not/exist for XXX: No such file or directory (os error 2)", - ); - - assert_eq!(Ok(None), expand_to_dict("@?/does/not/exist".to_string())); - - // Test an optional fromfile that does exist, to ensure we handle the `?` in this case. - let res = expand_fromfile("{'FOO': 42}", "@?", "fromfile.txt"); - assert_eq!( - replace(hashmap! {"FOO".to_string() => Val::Int(42),}), - res.unwrap().unwrap() - ); -}