use std::collections::{BTreeMap, BTreeSet};
use isoloom_manifest::{Part, Placeholder, RunVar, Template};
use crate::Result;
use crate::error::Error;
pub(crate) type PortKey = (String, String);
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct Vars {
run: BTreeMap<RunVar, String>,
params: BTreeMap<String, String>,
outputs: BTreeMap<String, String>,
published: BTreeMap<PortKey, u16>,
hosts: BTreeMap<String, String>,
machine: Option<String>,
}
impl Vars {
pub(crate) fn set_run(&mut self, var: RunVar, value: impl Into<String>) {
self.run.insert(var, value.into());
}
pub(crate) fn set_params(&mut self, params: BTreeMap<String, String>) {
self.params = params;
}
pub(crate) fn params(&self) -> &BTreeMap<String, String> {
&self.params
}
pub(crate) fn set_published(&mut self, published: BTreeMap<PortKey, u16>) {
self.published = published;
}
pub(crate) fn set_hosts(&mut self, hosts: BTreeMap<String, String>) {
self.hosts = hosts;
}
pub(crate) fn published(&self) -> &BTreeMap<PortKey, u16> {
&self.published
}
pub(crate) fn set_machine(&mut self, machine: &str) {
self.machine = Some(machine.to_owned());
}
pub(crate) fn set_output(&mut self, name: &str, value: String) {
self.outputs.insert(name.to_owned(), value);
}
pub(crate) fn awaits_state(&self, placeholder: &Placeholder) -> bool {
match placeholder {
Placeholder::Output(name) => !self.outputs.contains_key(name.as_str()),
Placeholder::Host(machine) => !self.hosts.contains_key(machine.as_str()),
_ => false,
}
}
pub(crate) fn with_arg(&self, with: &std::collections::BTreeMap<isoloom_manifest::SnakeName, Template>, at: &str, name: &str) -> Result<Option<String>> {
with.iter()
.find(|(k, _)| k.as_str() == name)
.map(|(_, t)| self.render(t, &format!("{at}.with.{name}")))
.transpose()
}
pub(crate) fn render(&self, template: &Template, at: &str) -> Result<String> {
self.fill(template, None, at)
}
pub(crate) fn render_value(&self, template: &Template, value: &str, at: &str) -> Result<String> {
self.fill(template, Some(value), at)
}
pub(crate) fn render_lenient(&self, template: &Template, hidden: &BTreeSet<String>) -> String {
template
.parts()
.iter()
.map(|part| match part {
Part::Text(t) => t.clone(),
Part::Placeholder(Placeholder::Output(name)) if hidden.contains(name.as_str()) => {
format!("${{{}}}", Placeholder::Output(name.clone()))
}
Part::Placeholder(p) => self.value(p, None).unwrap_or_else(|| format!("${{{p}}}")),
})
.collect()
}
fn fill(&self, template: &Template, value: Option<&str>, at: &str) -> Result<String> {
let mut out = String::new();
for part in template.parts() {
match part {
Part::Text(t) => out.push_str(t),
Part::Placeholder(p) => {
let v = self.value(p, value).ok_or_else(|| Error::Unbound {
at: at.to_owned(),
placeholder: p.to_string(),
})?;
out.push_str(&v);
}
}
}
Ok(out)
}
fn value(&self, placeholder: &Placeholder, value: Option<&str>) -> Option<String> {
match placeholder {
Placeholder::Run(var) => self.run.get(var).cloned(),
Placeholder::Param(name) => self.params.get(name.as_str()).cloned(),
Placeholder::Output(name) => self.outputs.get(name.as_str()).cloned(),
Placeholder::Published { machine, port } => self.published.get(&(machine.to_string(), port.to_string())).map(u16::to_string),
Placeholder::Host(machine) => self.hosts.get(machine.as_str()).cloned(),
Placeholder::Value => value.map(str::to_owned),
Placeholder::Machine => self.machine.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn t(text: &str) -> Template {
Template::parse(text).unwrap()
}
fn vars() -> Vars {
let mut v = Vars::default();
v.set_run(RunVar::Name, "flag-portal");
v.set_run(RunVar::StateDir, "/home/u/.isothrum/runs/flag-portal");
v.set_params(BTreeMap::from([("ISOLOOM_DERIVED".to_owned(), "1".to_owned())]));
v.set_published(BTreeMap::from([(("web".to_owned(), "portal".to_owned()), 41873)]));
v.set_hosts(BTreeMap::from([("web".to_owned(), "127.0.0.1".to_owned())]));
v.set_output("secret_token", "s3cr3t".into());
v
}
#[test]
fn placeholders_take_the_operations_values() {
let v = vars();
assert_eq!(
v.render(&t("${run.state_dir}/ports.yml"), "x").unwrap(),
"/home/u/.isothrum/runs/flag-portal/ports.yml"
);
assert_eq!(
v.render(&t("${run.name}_lab cost $$5 ${params.ISOLOOM_DERIVED}"), "x").unwrap(),
"flag-portal_lab cost $5 1"
);
assert_eq!(
v.render_value(&t("http://127.0.0.1:${value}/"), "41873", "x").unwrap(),
"http://127.0.0.1:41873/"
);
assert_eq!(v.render(&t("${published.web.portal}"), "x").unwrap(), "41873");
assert_eq!(
v.render_value(&t("http://${host.web}:${value}/"), "41873", "x").unwrap(),
"http://127.0.0.1:41873/"
);
}
#[test]
fn a_placeholder_without_a_value_is_an_error_naming_it() {
let err = vars().render(&t("--provider ${run.provider}"), "lifecycle.up.up").unwrap_err();
assert_eq!(err.to_string(), "lifecycle.up.up: `${run.provider}` has no value in this operation");
assert!(vars().render(&t("${published.web.admin}"), "x").is_err());
assert!(vars().render(&t("${value}"), "x").is_err());
let unbound_host = vars().render(&t("http://${host.db}/"), "outputs.db_site.format").unwrap_err();
assert_eq!(unbound_host.to_string(), "outputs.db_site.format: `${host.db}` has no value in this operation");
}
#[test]
fn a_message_never_shows_a_sensitive_output_or_an_unknown_value() {
let hidden = BTreeSet::from(["secret_token".to_owned()]);
let msg = t("Open http://localhost:${published.web.portal}/ with ${outputs.secret_token}; ${published.db.mysql}");
assert_eq!(
vars().render_lenient(&msg, &hidden),
"Open http://localhost:41873/ with ${outputs.secret_token}; ${published.db.mysql}"
);
}
}
#[cfg(test)]
mod proptests {
use proptest::prelude::*;
use super::*;
proptest! {
#[test]
fn text_with_no_placeholder_renders_as_itself(text in "\\PC{0,80}") {
let template = Template::parse(&text.replace('$', "$$")).unwrap();
prop_assert_eq!(Vars::default().render(&template, "x").unwrap(), text);
}
#[test]
fn a_bound_placeholder_is_replaced_wherever_it_sits(before in "[^$]{0,20}", after in "[^$]{0,20}", name in "[a-z0-9][a-z0-9_-]{0,30}") {
let mut vars = Vars::default();
vars.set_run(RunVar::Name, name.clone());
let template = Template::parse(&format!("{before}${{run.name}}{after}")).unwrap();
prop_assert_eq!(vars.render(&template, "x").unwrap(), format!("{before}{name}{after}"));
}
#[test]
fn a_value_is_never_read_as_a_placeholder_itself(value in "\\PC{0,40}") {
let mut vars = Vars::default();
vars.set_params(BTreeMap::from([("P".to_owned(), value.clone())]));
let template = Template::parse("<${params.P}>").unwrap();
prop_assert_eq!(vars.render(&template, "x").unwrap(), format!("<{value}>"));
}
#[test]
fn a_message_is_always_filled_or_kept_as_written(text in "[a-z ]{0,20}", port in 1u16..) {
let template = Template::parse(&format!("{text}${{published.web.portal}}")).unwrap();
let mut vars = Vars::default();
prop_assert_eq!(vars.render_lenient(&template, &BTreeSet::new()), format!("{text}${{published.web.portal}}"));
vars.set_published(BTreeMap::from([(("web".to_owned(), "portal".to_owned()), port)]));
prop_assert_eq!(vars.render_lenient(&template, &BTreeSet::new()), format!("{text}{port}"));
}
}
}