diff options
Diffstat (limited to 'src/config/import.rs')
| -rw-r--r-- | src/config/import.rs | 100 |
1 files changed, 58 insertions, 42 deletions
diff --git a/src/config/import.rs b/src/config/import.rs index 4602705..a5b2a63 100644 --- a/src/config/import.rs +++ b/src/config/import.rs @@ -3,10 +3,7 @@ //! //! * `env:NAME`: an environment variable; //! * `file:PATH`: a file's contents; -//! * `cmd:COMMAND`: the standard output of a shell command (`sh -c`); -//! * `template:TEXT`: text with importers in it, `${env:NAME}`, `${file:PATH}` or -//! `${cmd:COMMAND}`, each replaced by its content (`$$` is a `$`; a placeholder ends -//! at the first `}`), e.g. `template:node-${env:HOSTNAME}`. +//! * `cmd:COMMAND`: the standard output of a shell command (`sh -c`). //! //! The content is text (trailing line breaks removed), converted like a value written //! on the command line; with `+json` (`env+json:NAME`, …) it is parsed as JSON instead. @@ -14,9 +11,12 @@ //! On the command line, a value with one of these prefixes is imported; `literal:` //! passes the rest as is. In configuration files, an importer is a table: //! `{ env = "NAME" }`, `{ file = "PATH" }`, `{ command = "COMMAND" }` (or an argument -//! list, run without a shell), `{ template = "TEXT" }`, each with an optional -//! `format = "json"`. Relative paths are relative to the file, and commands run in its -//! directory, also inside a template. +//! list, run without a shell), each with an optional `format = "json"`. +//! +//! Any other text value may hold placeholders, `${env:NAME}`, `${file:PATH}` or +//! `${cmd:COMMAND}`, each replaced by its content as text (`$$` is a `$`; a placeholder +//! ends at the first `}`), e.g. `node-${env:HOSTNAME}`. Relative paths are relative to +//! the file, and commands run in its directory, in placeholders too. use std::path::{Path, PathBuf}; use std::process::Command; @@ -42,6 +42,7 @@ pub(crate) enum Source { argv: Vec<String>, dir: Option<PathBuf>, }, + /// Text with placeholders (`interpolated`). Template { template: String, dir: Option<PathBuf>, @@ -67,12 +68,12 @@ pub(crate) fn parse_argument(raw: &str) -> Raw<'_> { return Raw::Literal(rest); } let Some((prefix, rest)) = raw.split_once(':') else { - return Raw::Literal(raw); + return literal_or_interpolated(raw); }; let (kind, format) = match prefix.split_once('+') { None => (prefix, Format::Text), Some((kind, "json")) => (kind, Format::Json), - Some(_) => return Raw::Literal(raw), + Some(_) => return literal_or_interpolated(raw), }; let source = match kind { "env" => Source::Env(rest.to_owned()), @@ -81,21 +82,36 @@ pub(crate) fn parse_argument(raw: &str) -> Raw<'_> { command: rest.to_owned(), dir: None, }, - "template" => Source::Template { - template: rest.to_owned(), - dir: None, - }, - _ => return Raw::Literal(raw), + _ => return literal_or_interpolated(raw), }; Raw::Import(Importer { source, format }) } +fn literal_or_interpolated(raw: &str) -> Raw<'_> { + match interpolated(raw, None) { + Some(importer) => Raw::Import(importer), + None => Raw::Literal(raw), + } +} + +/// `text` as an importer if it holds a `$` (a placeholder, `$$`, or a mistake that +/// expanding it reports), read in `dir`; None if it is plain text. +pub(crate) fn interpolated(text: &str, dir: Option<&Path>) -> Option<Importer> { + text.contains('$').then(|| Importer { + source: Source::Template { + template: text.to_owned(), + dir: dir.map(Path::to_owned), + }, + format: Format::Text, + }) +} + /// Reads an importer table from a file in `dir`; None if `object` isn't one. pub(crate) fn parse_table( object: &JsonMap<String, JsonValue>, dir: &Path, ) -> Result<Option<Importer>> { - let sources = ["env", "file", "command", "template"] + let sources = ["env", "file", "command"] .into_iter() .filter(|key| object.contains_key(*key)) .count(); @@ -103,11 +119,11 @@ pub(crate) fn parse_table( return Ok(None); } if sources > 1 { - bail!("an importer takes exactly one of env, file, command and template"); + bail!("an importer takes exactly one of env, file and command"); } if let Some(unknown) = object .keys() - .find(|key| !["env", "file", "command", "template", "format"].contains(&key.as_str())) + .find(|key| !["env", "file", "command", "format"].contains(&key.as_str())) { bail!("unknown importer key {unknown:?}"); } @@ -127,11 +143,6 @@ pub(crate) fn parse_table( Source::Env(text("env")?) } else if object.contains_key("file") { Source::File(dir.join(text("file")?)) - } else if object.contains_key("template") { - Source::Template { - template: text("template")?, - dir: Some(dir.to_owned()), - } } else { match &object["command"] { JsonValue::String(command) => Source::Shell { @@ -170,7 +181,7 @@ impl Importer { Source::File(path) => format!("file{format}:{}", path.display()), Source::Shell { command, .. } => format!("cmd{format}:{command}"), Source::Argv { argv, .. } => format!("cmd{format}:{}", argv.join(" ")), - Source::Template { template, .. } => format!("template{format}:{template}"), + Source::Template { template, .. } => template.clone(), } } @@ -211,7 +222,7 @@ pub(crate) enum Imported { Json(JsonValue), } -/// A template with its placeholders replaced by their content. +/// Text with its placeholders replaced by their content. fn expand(template: &str, dir: Option<&Path>) -> Result<String> { let mut out = String::new(); let mut rest = template; @@ -238,13 +249,11 @@ fn expand(template: &str, dir: Option<&Path>) -> Result<String> { } } Source::Shell { dir: run_in, .. } => *run_in = dir.map(Path::to_owned), - Source::Template { .. } => bail!("templates don't nest: {placeholder:?}"), + Source::Template { .. } => bail!("placeholders don't nest: {placeholder:?}"), Source::Env(_) | Source::Argv { .. } => {} } if importer.format != Format::Text { - bail!( - "placeholder {placeholder:?} must be text; +json applies to the whole template" - ); + bail!("placeholder {placeholder:?} must be text (+json isn't for placeholders)"); } match importer.fetch()? { Imported::Text(text) => out.push_str(&text), @@ -252,8 +261,11 @@ fn expand(template: &str, dir: Option<&Path>) -> Result<String> { } rest = &after[end + 1..]; } else { - out.push('$'); - rest = after; + // Most likely a shell-style `$NAME` that would silently stay as it is. + bail!( + "a `$` in {template:?} that starts no placeholder: write `$$` for a `$`, or \ + e.g. `${{env:NAME}}` for a variable" + ); } } out.push_str(rest); @@ -308,8 +320,11 @@ mod tests { }) ); assert_eq!(parse_argument("env+yaml:X"), Raw::Literal("env+yaml:X")); + // A `$` makes it interpolated; expanding it then says what's wrong. + assert!(matches!(parse_argument("a $5 price"), Raw::Import(_))); + assert_eq!(parse_argument("literal:a $5"), Raw::Literal("a $5")); assert_eq!( - parse_argument("template:node-${env:HOST}"), + parse_argument("node-${env:HOST}"), Raw::Import(Importer { source: Source::Template { template: "node-${env:HOST}".into(), @@ -318,12 +333,23 @@ mod tests { format: Format::Text }) ); + // A placeholder in a value that looks like a prefix is interpolated too. + assert!(matches!( + parse_argument("tls://${env:HOST}:9651"), + Raw::Import(_) + )); + assert_eq!( + parse_argument("literal:${env:HOST}"), + Raw::Literal("${env:HOST}") + ); } #[test] fn templates() { let expanded = |template: &str| expand(template, None); - assert_eq!(expanded("a-${cmd:echo b}-c $$5 $x").unwrap(), "a-b-c $5 $x"); + assert_eq!(expanded("a-${cmd:echo b}-c $$5").unwrap(), "a-b-c $5"); + assert!(expanded("$HOME/x").is_err()); + assert!(expanded("a $5 price").is_err()); assert_eq!( expanded("${cmd:printf one}${cmd:printf two}").unwrap(), "onetwo" @@ -331,16 +357,6 @@ mod tests { assert!(expanded("${cmd:echo b").is_err()); assert!(expanded("${plain}").is_err()); assert!(expanded("${env+json:X}").is_err()); - assert!(expanded("${template:x}").is_err()); - - let json = Importer { - source: Source::Template { - template: "[\"${cmd:printf a}\", 1]".into(), - dir: None, - }, - format: Format::Json, - }; - assert_eq!(json.fetch().unwrap(), Imported::Json(json!(["a", 1]))); } #[test] |
