summaryrefslogtreecommitdiffstats
path: root/src/config
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-10-25 13:43:42 +0100
committertv <tv@krebsco.de>2026-10-25 13:43:42 +0100
commitcc09b7bbb2b4fa33636719c4b99cf0007982d179 (patch)
treebb376addd26beed0765bf3cc3088f3f880bc4bcb /src/config
parent714debc94b0ef2b543949241f1eb04ae5738ad0c (diff)
add template importer to config
Diffstat (limited to 'src/config')
-rw-r--r--src/config/import.rs113
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]