use regex::Regex;
use std::collections::{BTreeMap, HashMap};
use std::sync::LazyLock;
use crate::config::model::DevrigConfig;
use crate::orchestrator::state::ClusterDeployState;
const TEMPLATE_SUGGESTION_THRESHOLD: f64 = 0.8;
static TEMPLATE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\{\{\s*([\w.\-]+)\s*\}\}").expect("template regex must compile"));
#[derive(Debug, thiserror::Error)]
pub enum TemplateError {
#[error("unresolved variable '{{{{{{ {variable} }}}}}}' in {field}{}", suggestion.as_ref().map(|s| format!(" (did you mean `{}`?)", s)).unwrap_or_default())]
UnresolvedVariable {
field: String,
variable: String,
suggestion: Option<String>,
},
}
fn find_closest_template_var<'a>(name: &str, vars: &'a HashMap<String, String>) -> Option<&'a str> {
let mut best: Option<(&str, f64)> = None;
for key in vars.keys() {
let score = strsim::jaro_winkler(name, key);
if score >= TEMPLATE_SUGGESTION_THRESHOLD && best.is_none_or(|(_, s)| score > s) {
best = Some((key.as_str(), score));
}
}
best.map(|(name, _)| name)
}
pub fn resolve_template(
input: &str,
vars: &HashMap<String, String>,
field_context: &str,
) -> Result<String, Vec<TemplateError>> {
let errors: Vec<TemplateError> = TEMPLATE_RE
.captures_iter(input)
.filter_map(|cap| {
let variable = cap[1].to_string();
if vars.contains_key(&variable) {
None
} else {
let suggestion = find_closest_template_var(&variable, vars).map(String::from);
Some(TemplateError::UnresolvedVariable {
field: field_context.to_string(),
variable,
suggestion,
})
}
})
.collect();
if !errors.is_empty() {
return Err(errors);
}
let resolved = TEMPLATE_RE
.replace_all(input, |cap: ®ex::Captures| {
let key = &cap[1];
vars.get(key).cloned().unwrap_or_default()
})
.into_owned();
Ok(resolved)
}
pub fn build_template_vars(
config: &DevrigConfig,
resolved_ports: &HashMap<String, u16>,
) -> HashMap<String, String> {
let mut vars = HashMap::new();
vars.insert("project.name".to_string(), config.project.name.clone());
for name in config.services.keys() {
let port_key = format!("service:{name}");
if let Some(&port) = resolved_ports.get(&port_key) {
vars.insert(format!("services.{name}.port"), port.to_string());
}
}
for (name, docker_cfg) in &config.docker {
let port_key = format!("docker:{name}");
if let Some(&port) = resolved_ports.get(&port_key) {
vars.insert(format!("docker.{name}.port"), port.to_string());
}
for pname in docker_cfg.ports.keys() {
let port_key = format!("docker:{name}:{pname}");
if let Some(&port) = resolved_ports.get(&port_key) {
let val = port.to_string();
vars.insert(format!("docker.{name}.ports.{pname}"), val.clone());
vars.insert(format!("docker.{name}.port_{pname}"), val);
}
}
}
if let Some(cluster) = &config.cluster {
let cluster_name = cluster
.name
.clone()
.unwrap_or_else(|| format!("{}-dev", config.project.name));
vars.insert("cluster.name".to_string(), cluster_name);
}
if config.dashboard.is_some() {
if let Some(&port) = resolved_ports.get("dashboard") {
vars.insert("dashboard.port".to_string(), port.to_string());
}
if let Some(&port) = resolved_ports.get("otel-grpc") {
vars.insert("dashboard.otel.grpc_port".to_string(), port.to_string());
}
if let Some(&port) = resolved_ports.get("otel-http") {
vars.insert("dashboard.otel.http_port".to_string(), port.to_string());
}
}
vars
}
pub fn build_cluster_image_vars(
deployed: &BTreeMap<String, ClusterDeployState>,
) -> HashMap<String, String> {
let mut vars = HashMap::new();
for (name, state) in deployed {
let tag = state
.image_tag
.rsplit_once(':')
.map(|(_, t)| t)
.unwrap_or(&state.image_tag);
vars.insert(format!("cluster.image.{name}.tag"), tag.to_string());
}
vars
}
pub fn resolve_config_templates(
config: &mut DevrigConfig,
vars: &HashMap<String, String>,
) -> Result<(), Vec<TemplateError>> {
let mut all_errors: Vec<TemplateError> = Vec::new();
for (env_key, env_val) in &mut config.env {
let field_context = format!("env.{env_key}");
match resolve_template(env_val, vars, &field_context) {
Ok(resolved) => *env_val = resolved,
Err(mut errs) => all_errors.append(&mut errs),
}
}
for (svc_name, svc) in &mut config.services {
for (env_key, env_val) in &mut svc.env {
let field_context = format!("services.{svc_name}.env.{env_key}");
match resolve_template(env_val, vars, &field_context) {
Ok(resolved) => *env_val = resolved,
Err(mut errs) => all_errors.append(&mut errs),
}
}
}
if let Some(ref mut oidc) = config.oidc {
if let Some(ref mut issuer) = oidc.issuer {
match resolve_template(issuer, vars, "oidc.issuer") {
Ok(resolved) => *issuer = resolved,
Err(mut errs) => all_errors.append(&mut errs),
}
}
for (client_id, client) in &mut oidc.clients {
for uri in &mut client.redirect_uris {
let field_context = format!("oidc.clients.{client_id}.redirect_uris");
match resolve_template(uri, vars, &field_context) {
Ok(resolved) => *uri = resolved,
Err(mut errs) => all_errors.append(&mut errs),
}
}
}
}
if all_errors.is_empty() {
Ok(())
} else {
Err(all_errors)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::model::{DevrigConfig, DockerConfig, Port, ProjectConfig, ServiceConfig};
use std::collections::BTreeMap;
fn make_vars() -> HashMap<String, String> {
let mut vars = HashMap::new();
vars.insert("docker.postgres.port".to_string(), "5432".to_string());
vars.insert("docker.redis.port".to_string(), "6379".to_string());
vars.insert("project.name".to_string(), "myapp".to_string());
vars.insert("docker.mailpit.ports.smtp".to_string(), "1025".to_string());
vars
}
#[test]
fn basic_substitution() {
let vars = make_vars();
let result =
resolve_template("port={{ docker.postgres.port }}", &vars, "test_field").unwrap();
assert_eq!(result, "port=5432");
}
#[test]
fn multiple_substitutions() {
let vars = make_vars();
let input = "pg={{ docker.postgres.port }},redis={{ docker.redis.port }}";
let result = resolve_template(input, &vars, "test_field").unwrap();
assert_eq!(result, "pg=5432,redis=6379");
}
#[test]
fn unresolved_variable_error() {
let vars = make_vars();
let result = resolve_template(
"host={{ docker.mysql.port }}",
&vars,
"services.api.env.DB_HOST",
);
assert!(result.is_err());
let errors = result.unwrap_err();
assert_eq!(errors.len(), 1);
match &errors[0] {
TemplateError::UnresolvedVariable {
field, variable, ..
} => {
assert_eq!(field, "services.api.env.DB_HOST");
assert_eq!(variable, "docker.mysql.port");
}
}
}
#[test]
fn unresolved_variable_suggests_close_match() {
let vars = make_vars();
let result = resolve_template(
"port={{ docker.postgres.prot }}",
&vars,
"services.api.env.PORT",
);
assert!(result.is_err());
let errors = result.unwrap_err();
assert_eq!(errors.len(), 1);
match &errors[0] {
TemplateError::UnresolvedVariable { suggestion, .. } => {
assert_eq!(suggestion.as_deref(), Some("docker.postgres.port"));
}
}
}
#[test]
fn port_alias_works_in_template() {
let mut vars = make_vars();
vars.insert("docker.mailpit.port_smtp".to_string(), "1025".to_string());
let result =
resolve_template("smtp={{ docker.mailpit.port_smtp }}", &vars, "test").unwrap();
assert_eq!(result, "smtp=1025");
}
#[test]
fn no_templates_is_noop() {
let vars = make_vars();
let input = "plain string with no templates";
let result = resolve_template(input, &vars, "test_field").unwrap();
assert_eq!(result, input);
}
#[test]
fn whitespace_in_braces() {
let vars = make_vars();
let result =
resolve_template("port={{ docker.postgres.port }}", &vars, "test_field").unwrap();
assert_eq!(result, "port=5432");
}
#[test]
fn build_template_vars_produces_correct_keys() {
let mut services = BTreeMap::new();
services.insert(
"api".to_string(),
ServiceConfig {
path: None,
command: "cargo run".to_string(),
port: Some(Port::Auto),
protocol: None,
env: BTreeMap::new(),
env_file: None,
depends_on: vec![],
restart: None,
},
);
let mut mailpit_ports = BTreeMap::new();
mailpit_ports.insert("smtp".to_string(), Port::Fixed(1025));
mailpit_ports.insert("ui".to_string(), Port::Fixed(8025));
let mut docker_map = BTreeMap::new();
docker_map.insert(
"postgres".to_string(),
DockerConfig {
image: "postgres:16".to_string(),
port: Some(Port::Fixed(5432)),
container_port: None,
protocol: None,
ports: BTreeMap::new(),
env: BTreeMap::new(),
volumes: vec![],
command: None,
entrypoint: None,
ready_check: None,
init: vec![],
depends_on: vec![],
registry_auth: None,
},
);
docker_map.insert(
"mailpit".to_string(),
DockerConfig {
image: "axllent/mailpit:latest".to_string(),
port: None,
container_port: None,
protocol: None,
ports: mailpit_ports,
env: BTreeMap::new(),
volumes: vec![],
command: None,
entrypoint: None,
ready_check: None,
init: vec![],
depends_on: vec![],
registry_auth: None,
},
);
let config = DevrigConfig {
project: ProjectConfig {
name: "myapp".to_string(),
env_file: None,
},
services,
docker: docker_map,
compose: None,
cluster: None,
dashboard: None,
oidc: None,
env: BTreeMap::new(),
network: None,
links: BTreeMap::new(),
};
let mut resolved_ports = HashMap::new();
resolved_ports.insert("service:api".to_string(), 3000u16);
resolved_ports.insert("docker:postgres".to_string(), 5432u16);
resolved_ports.insert("docker:mailpit:smtp".to_string(), 1025u16);
resolved_ports.insert("docker:mailpit:ui".to_string(), 8025u16);
let vars = build_template_vars(&config, &resolved_ports);
assert_eq!(vars.get("project.name").unwrap(), "myapp");
assert_eq!(vars.get("services.api.port").unwrap(), "3000");
assert_eq!(vars.get("docker.postgres.port").unwrap(), "5432");
assert_eq!(vars.get("docker.mailpit.ports.smtp").unwrap(), "1025");
assert_eq!(vars.get("docker.mailpit.ports.ui").unwrap(), "8025");
assert_eq!(vars.get("docker.mailpit.port_smtp").unwrap(), "1025");
assert_eq!(vars.get("docker.mailpit.port_ui").unwrap(), "8025");
}
#[test]
fn dashboard_template_vars() {
use crate::config::model::{DashboardConfig, OtelConfig};
let config = DevrigConfig {
project: ProjectConfig {
name: "myapp".to_string(),
env_file: None,
},
services: BTreeMap::new(),
docker: BTreeMap::new(),
compose: None,
cluster: None,
oidc: None,
dashboard: Some(DashboardConfig {
port: Port::Fixed(5000),
enabled: None,
otel: Some(OtelConfig {
grpc_port: Port::Fixed(14317),
http_port: Port::Fixed(14318),
..OtelConfig::default()
}),
}),
env: BTreeMap::new(),
network: None,
links: BTreeMap::new(),
};
let mut resolved_ports = HashMap::new();
resolved_ports.insert("dashboard".to_string(), 5000u16);
resolved_ports.insert("otel-grpc".to_string(), 14317u16);
resolved_ports.insert("otel-http".to_string(), 14318u16);
let vars = build_template_vars(&config, &resolved_ports);
assert_eq!(vars.get("dashboard.port").unwrap(), "5000");
assert_eq!(vars.get("dashboard.otel.grpc_port").unwrap(), "14317");
assert_eq!(vars.get("dashboard.otel.http_port").unwrap(), "14318");
}
#[test]
fn cluster_name_template_var() {
let mut config = DevrigConfig {
project: ProjectConfig {
name: "myapp".to_string(),
env_file: None,
},
services: BTreeMap::new(),
docker: BTreeMap::new(),
compose: None,
cluster: Some(crate::config::model::ClusterConfig {
name: Some("my-cluster".to_string()),
agents: 1,
ports: vec![],
volumes: vec![],
registry: false,
images: BTreeMap::new(),
deploy: BTreeMap::new(),
addons: BTreeMap::new(),
logs: None,
registries: vec![],
k3s_args: vec![],
}),
dashboard: None,
oidc: None,
env: BTreeMap::new(),
network: None,
links: BTreeMap::new(),
};
let resolved_ports = HashMap::new();
let vars = build_template_vars(&config, &resolved_ports);
assert_eq!(vars.get("cluster.name").unwrap(), "my-cluster");
config.cluster.as_mut().unwrap().name = None;
let vars = build_template_vars(&config, &resolved_ports);
assert_eq!(vars.get("cluster.name").unwrap(), "myapp-dev");
}
#[test]
fn resolve_config_templates_resolves_global_env() {
let mut config = DevrigConfig {
project: ProjectConfig {
name: "myapp".to_string(),
env_file: None,
},
services: BTreeMap::new(),
docker: BTreeMap::new(),
compose: None,
cluster: None,
dashboard: None,
oidc: None,
env: BTreeMap::from([
(
"DATABASE_URL".to_string(),
"postgres://localhost:{{ docker.postgres.port }}/mydb".to_string(),
),
("PLAIN".to_string(), "no-templates-here".to_string()),
]),
network: None,
links: BTreeMap::new(),
};
let mut vars = HashMap::new();
vars.insert("docker.postgres.port".to_string(), "5432".to_string());
resolve_config_templates(&mut config, &vars).unwrap();
assert_eq!(
config.env.get("DATABASE_URL").unwrap(),
"postgres://localhost:5432/mydb"
);
assert_eq!(config.env.get("PLAIN").unwrap(), "no-templates-here");
}
}