use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use super::NpmError;
#[derive(Debug)]
pub enum RenderedManifest {
Json(serde_json::Value),
Toml(String),
}
pub struct ManifestRenderer<'a> {
variables: &'a BTreeMap<String, String>,
}
impl<'a> ManifestRenderer<'a> {
pub fn new(variables: &'a BTreeMap<String, String>) -> Self {
Self { variables }
}
pub fn render(&self, src: &Path) -> Result<RenderedManifest, NpmError> {
let text = fs::read_to_string(src).map_err(|source| NpmError::Read {
path: src.to_path_buf(),
source,
})?;
match src.extension().and_then(|extension| extension.to_str()) {
Some("json") => {
let mut value: serde_json::Value =
serde_json::from_str(&text).map_err(|source| NpmError::ParseJson {
path: src.to_path_buf(),
source,
})?;
self.substitute_json(&mut value, src)?;
Ok(RenderedManifest::Json(value))
}
Some("toml") => {
let mut value: toml::Value =
toml::from_str(&text).map_err(|source| NpmError::ParseToml {
path: src.to_path_buf(),
source,
})?;
self.substitute_toml(&mut value, src)?;
let mut rendered =
toml::to_string_pretty(&value).map_err(|source| NpmError::SerializeToml {
path: src.to_path_buf(),
source,
})?;
if !rendered.ends_with('\n') {
rendered.push('\n');
}
Ok(RenderedManifest::Toml(rendered))
}
_ => Err(NpmError::UnsupportedManifestFormat {
path: src.to_path_buf(),
}),
}
}
fn substitute_json(&self, value: &mut serde_json::Value, src: &Path) -> Result<(), NpmError> {
match value {
serde_json::Value::String(text) => *text = self.expand(text, src)?,
serde_json::Value::Array(items) => {
for item in items {
self.substitute_json(item, src)?;
}
}
serde_json::Value::Object(map) => {
for item in map.values_mut() {
self.substitute_json(item, src)?;
}
}
_ => {}
}
Ok(())
}
fn substitute_toml(&self, value: &mut toml::Value, src: &Path) -> Result<(), NpmError> {
match value {
toml::Value::String(text) => *text = self.expand(text, src)?,
toml::Value::Array(items) => {
for item in items {
self.substitute_toml(item, src)?;
}
}
toml::Value::Table(map) => {
for (_, item) in map.iter_mut() {
self.substitute_toml(item, src)?;
}
}
_ => {}
}
Ok(())
}
fn expand(&self, input: &str, src: &Path) -> Result<String, NpmError> {
const OPEN: &str = "${";
let mut out = String::with_capacity(input.len());
let mut rest = input;
while let Some(start) = rest.find(OPEN) {
out.push_str(&rest[..start]);
let after = &rest[start + OPEN.len()..];
let end = after
.find('}')
.ok_or_else(|| NpmError::UnterminatedPlaceholder {
path: src.to_path_buf(),
})?;
let name = &after[..end];
let value = self
.variables
.get(name)
.ok_or_else(|| NpmError::UnknownVariable {
name: name.to_owned(),
path: src.to_path_buf(),
})?;
out.push_str(value);
rest = &after[end + 1..];
}
out.push_str(rest);
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn variables() -> BTreeMap<String, String> {
BTreeMap::from([
("name".to_owned(), "nocmd".to_owned()),
("version".to_owned(), "0.1.1".to_owned()),
(
"description".to_owned(),
"say \"hi\"\\done\nnext".to_owned(),
),
])
}
fn scratch(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("npmgen-subst-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir.join(name)
}
#[test]
fn substitution_at_data_layer_stays_valid_json() {
let path = scratch("plugin.json");
std::fs::write(
&path,
r#"{ "name": "${name}", "version": "${version}", "blurb": "${description}" }"#,
)
.unwrap();
let variables = variables();
let rendered = ManifestRenderer::new(&variables).render(&path).unwrap();
let RenderedManifest::Json(value) = rendered else {
panic!("expected json");
};
assert_eq!(value["blurb"], serde_json::json!(variables["description"]));
let serialized = serde_json::to_string(&value).unwrap();
let reparsed: serde_json::Value = serde_json::from_str(&serialized).unwrap();
assert_eq!(
reparsed["blurb"],
serde_json::json!(variables["description"])
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn nested_placeholders_are_expanded() {
let path = scratch("nested.json");
std::fs::write(
&path,
r#"{ "a": ["${name}"], "b": { "c": "v${version}" } }"#,
)
.unwrap();
let variables = variables();
let RenderedManifest::Json(value) =
ManifestRenderer::new(&variables).render(&path).unwrap()
else {
panic!("expected json");
};
assert_eq!(value["a"][0], serde_json::json!("nocmd"));
assert_eq!(value["b"]["c"], serde_json::json!("v0.1.1"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn unknown_variable_is_an_error() {
let path = scratch("bad.json");
std::fs::write(&path, r#"{ "x": "${nope}" }"#).unwrap();
let variables = variables();
let error = ManifestRenderer::new(&variables).render(&path).unwrap_err();
assert!(matches!(error, NpmError::UnknownVariable { .. }));
let _ = std::fs::remove_file(&path);
}
#[test]
fn toml_substitution_round_trips_and_ends_with_newline() {
let path = scratch("manifest.toml");
std::fs::write(
&path,
"name = \"${name}\"\nblurb = \"${description}\"\n\n[nested]\ninner = \"${name}\"\nlist = [\"${version}\", \"plain\"]\n",
)
.unwrap();
let variables = variables();
let RenderedManifest::Toml(rendered) =
ManifestRenderer::new(&variables).render(&path).unwrap()
else {
panic!("expected toml");
};
assert!(rendered.ends_with('\n'));
let reparsed: toml::Value = toml::from_str(&rendered).unwrap();
assert_eq!(
reparsed["blurb"].as_str(),
Some(variables["description"].as_str())
);
assert_eq!(reparsed["nested"]["inner"].as_str(), Some("nocmd"));
assert_eq!(reparsed["nested"]["list"][0].as_str(), Some("0.1.1"));
let _ = std::fs::remove_file(&path);
}
}