diff options
| author | tv <tv@krebsco.de> | 2026-10-26 23:09:51 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-10-26 23:16:26 +0100 |
| commit | 932efcc8db81eda6f1d91fda74d002c0aecd6629 (patch) | |
| tree | 3ce99193569d9fbb9c141d0e20ca37ff78fd10c6 /src/config | |
| parent | c102987ebccc90706139e9c8cbc4e8a5323e9aa4 (diff) | |
allow placeholders everywhere in config
Diffstat (limited to 'src/config')
| -rw-r--r-- | src/config/import.rs | 100 | ||||
| -rw-r--r-- | src/config/mod.rs | 14 |
2 files changed, 72 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] diff --git a/src/config/mod.rs b/src/config/mod.rs index c8bfa9f..fd8547d 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -662,6 +662,13 @@ fn resolve_file_value( )), None => bail!("a table here must be an importer (env, file or command)"), }, + JsonValue::String(text) => match import::interpolated(&text, Some(dir)) { + Some(importer) => Ok(( + from_import(spec.kind, &importer)?, + vec![importer.describe()], + )), + None => Ok((JsonValue::String(text), Vec::new())), + }, JsonValue::Array(items) if spec.kind == Kind::List => { let mut imports = Vec::new(); let mut values = Vec::new(); @@ -677,6 +684,13 @@ fn resolve_file_value( } imports.push(importer.describe()); } + JsonValue::String(text) => match import::interpolated(&text, Some(dir)) { + Some(importer) => { + values.push(from_import(Kind::Text, &importer)?); + imports.push(importer.describe()); + } + None => values.push(JsonValue::String(text)), + }, other => values.push(other), } } |
