diff options
| -rw-r--r-- | src/config/import.rs | 113 |
1 files changed, 107 insertions, 6 deletions
diff --git a/src/config/import.rs b/src/config/import.rs index 895204a..4602705 100644 --- a/src/config/import.rs +++ b/src/config/import.rs @@ -3,7 +3,10 @@ //! //! * `env:NAME`: an environment variable; //! * `file:PATH`: a file's contents; -//! * `cmd:COMMAND`: the standard output of a shell command (`sh -c`). +//! * `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}`. //! //! 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. @@ -11,8 +14,9 @@ //! 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), each with an optional `format = "json"`. Relative paths -//! are relative to the file, and commands run in its directory. +//! 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. use std::path::{Path, PathBuf}; use std::process::Command; @@ -38,6 +42,10 @@ pub(crate) enum Source { argv: Vec<String>, dir: Option<PathBuf>, }, + Template { + template: String, + dir: Option<PathBuf>, + }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -73,6 +81,10 @@ 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), }; Raw::Import(Importer { source, format }) @@ -83,7 +95,7 @@ pub(crate) fn parse_table( object: &JsonMap<String, JsonValue>, dir: &Path, ) -> Result<Option<Importer>> { - let sources = ["env", "file", "command"] + let sources = ["env", "file", "command", "template"] .into_iter() .filter(|key| object.contains_key(*key)) .count(); @@ -91,11 +103,11 @@ pub(crate) fn parse_table( return Ok(None); } if sources > 1 { - bail!("an importer takes exactly one of env, file and command"); + bail!("an importer takes exactly one of env, file, command and template"); } if let Some(unknown) = object .keys() - .find(|key| !["env", "file", "command", "format"].contains(&key.as_str())) + .find(|key| !["env", "file", "command", "template", "format"].contains(&key.as_str())) { bail!("unknown importer key {unknown:?}"); } @@ -115,6 +127,11 @@ 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 { @@ -153,6 +170,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}"), } } @@ -174,6 +192,7 @@ impl Importer { process.args(&argv[1..]); run(process, dir.as_deref(), &argv.join(" "))? } + Source::Template { template, dir } => expand(template, dir.as_deref())?, }; let text = text.trim_end_matches(['\n', '\r']); Ok(match self.format { @@ -192,6 +211,55 @@ pub(crate) enum Imported { Json(JsonValue), } +/// A template 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; + while let Some(at) = rest.find('$') { + out.push_str(&rest[..at]); + let after = &rest[at + 1..]; + if let Some(after) = after.strip_prefix('$') { + out.push('$'); + rest = after; + } else if let Some(after) = after.strip_prefix('{') { + let end = after + .find('}') + .ok_or_else(|| anyhow!("unterminated placeholder in template {template:?}"))?; + let placeholder = &after[..end]; + let Raw::Import(mut importer) = parse_argument(placeholder) else { + bail!( + "placeholder {placeholder:?} in template {template:?} isn't env:, file: or cmd:" + ); + }; + match &mut importer.source { + Source::File(path) => { + if let Some(dir) = dir { + *path = dir.join(&*path); + } + } + Source::Shell { dir: run_in, .. } => *run_in = dir.map(Path::to_owned), + Source::Template { .. } => bail!("templates 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" + ); + } + match importer.fetch()? { + Imported::Text(text) => out.push_str(&text), + Imported::Json(_) => unreachable!("text importer"), + } + rest = &after[end + 1..]; + } else { + out.push('$'); + rest = after; + } + } + out.push_str(rest); + Ok(out) +} + fn run(mut process: Command, dir: Option<&Path>, shown: &str) -> Result<String> { if let Some(dir) = dir { process.current_dir(dir); @@ -240,6 +308,39 @@ mod tests { }) ); assert_eq!(parse_argument("env+yaml:X"), Raw::Literal("env+yaml:X")); + assert_eq!( + parse_argument("template:node-${env:HOST}"), + Raw::Import(Importer { + source: Source::Template { + template: "node-${env:HOST}".into(), + dir: None + }, + format: Format::Text + }) + ); + } + + #[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("${cmd:printf one}${cmd:printf two}").unwrap(), + "onetwo" + ); + 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] |
