use anyhow::{Context, Result};
use regex::Regex;
use std::sync::OnceLock;
use crate::errors;
pub(super) fn value_to_toml_inline(value: &toml::Value) -> String {
match value {
toml::Value::String(s) => format!(
"\"{}\"",
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t")
),
toml::Value::Integer(i) => i.to_string(),
toml::Value::Float(f) => f.to_string(),
toml::Value::Boolean(b) => b.to_string(),
toml::Value::Array(arr) => {
let items: Vec<String> = arr.iter().map(value_to_toml_inline).collect();
format!("[{}]", items.join(", "))
}
toml::Value::Table(table) => {
let items: Vec<String> = table
.iter()
.map(|(k, v)| format!("{} = {}", k, value_to_toml_inline(v)))
.collect();
format!("{{ {} }}", items.join(", "))
}
toml::Value::Datetime(dt) => dt.to_string(),
}
}
fn is_section_header(trimmed: &str) -> bool {
if !trimmed.starts_with('[') {
return false;
}
let header_part = trimmed.split('#').next().unwrap_or(trimmed).trim_end();
header_part.ends_with(']')
}
fn is_vars_header(trimmed: &str) -> bool {
if !trimmed.starts_with("[vars]") {
return false;
}
let rest = trimmed["[vars]".len()..].trim_start();
rest.is_empty() || rest.starts_with('#')
}
pub(super) fn remove_vars_section(content: &str) -> String {
let mut in_vars_section = false;
let lines: Vec<&str> = content
.lines()
.map(|line| {
let trimmed = line.trim();
if is_vars_header(trimmed) {
in_vars_section = true;
return "";
}
if in_vars_section && is_section_header(trimmed) {
in_vars_section = false;
}
if in_vars_section { "" } else { line }
})
.collect();
let mut result = lines.join("\n");
result.push('\n');
result
}
fn strip_toml_comment(line: &str) -> &str {
let mut in_basic = false; let mut in_literal = false; let mut escaped = false;
for (i, c) in line.char_indices() {
if escaped {
escaped = false;
continue;
}
match c {
'\\' if in_basic => escaped = true,
'"' if !in_literal => in_basic = !in_basic,
'\'' if !in_basic => in_literal = !in_literal,
'#' if !in_basic && !in_literal => return &line[..i],
_ => {}
}
}
line
}
fn resolve_var_value(
value: &toml::Value,
vars: &toml::map::Map<String, toml::Value>,
depth: usize,
) -> Result<toml::Value> {
if depth > 32 {
return Err(crate::exit_code::RsconstructError::new(
crate::exit_code::RsconstructExitCode::ConfigError,
"Variable reference cycle in [vars] (nesting exceeds 32 levels)".to_string(),
)
.into());
}
match value {
toml::Value::String(s) => {
if let Some(name) = s.strip_prefix("${").and_then(|r| r.strip_suffix('}'))
&& !name.contains('}')
{
let Some(referenced) = vars.get(name) else {
return Err(crate::exit_code::RsconstructError::new(
crate::exit_code::RsconstructExitCode::ConfigError,
format!("Undefined variable: ${{{name}}}"),
)
.into());
};
return resolve_var_value(referenced, vars, depth + 1);
}
Ok(value.clone())
}
toml::Value::Array(arr) => arr
.iter()
.map(|v| resolve_var_value(v, vars, depth + 1))
.collect::<Result<Vec<_>>>()
.map(toml::Value::Array),
toml::Value::Table(table) => table
.iter()
.map(|(k, v)| resolve_var_value(v, vars, depth + 1).map(|rv| (k.clone(), rv)))
.collect::<Result<toml::map::Map<_, _>>>()
.map(toml::Value::Table),
_ => Ok(value.clone()),
}
}
#[cfg(test)]
pub(super) fn extract_var_names(content: &str) -> Vec<String> {
let Ok(parsed) = toml::from_str::<toml::Value>(content) else {
return Vec::new();
};
var_names_of(&parsed)
}
fn var_names_of(parsed: &toml::Value) -> Vec<String> {
parsed
.get("vars")
.and_then(toml::Value::as_table)
.map(|vars| vars.keys().cloned().collect())
.unwrap_or_default()
}
pub(super) fn substitute_variables(content: &str) -> Result<String> {
static VAR_PATTERN: OnceLock<Regex> = OnceLock::new();
let var_pattern =
VAR_PATTERN.get_or_init(|| Regex::new(r#""\$\{([^}]+)\}""#).expect(errors::INVALID_REGEX));
let parsed = toml::from_str::<toml::Value>(content).ok();
let defined_vars = parsed.as_ref().map(var_names_of).unwrap_or_default();
for line in content.lines() {
for captures in var_pattern.captures_iter(strip_toml_comment(line)) {
let var_name = captures
.get(1)
.expect(errors::CAPTURE_GROUP_MISSING)
.as_str();
if !defined_vars.iter().any(|v| v == var_name) {
return Err(crate::exit_code::RsconstructError::new(
crate::exit_code::RsconstructExitCode::ConfigError,
format!("Undefined variable: ${{{var_name}}}"),
)
.into());
}
}
}
if defined_vars.is_empty() {
check_no_residual_references(content)?;
return Ok(content.to_string());
}
let parsed = parsed.context("Failed to parse TOML for variable extraction")?;
let Some(vars) = parsed.get("vars").and_then(|v| v.as_table()) else {
check_no_residual_references(content)?;
return Ok(content.to_string());
};
let mut result = content.to_string();
for (name, value) in vars {
let pattern = format!("\"${{{name}}}\"");
let resolved = resolve_var_value(value, vars, 0)?;
let replacement = value_to_toml_inline(&resolved);
result = result.replace(&pattern, &replacement);
}
let result = remove_vars_section(&result);
check_no_residual_references(&result)?;
Ok(result)
}
fn check_no_residual_references(content: &str) -> Result<()> {
static RESIDUAL_PATTERN: OnceLock<Regex> = OnceLock::new();
let residual =
RESIDUAL_PATTERN.get_or_init(|| Regex::new(r"\$\{([^}]+)\}").expect(errors::INVALID_REGEX));
for line in content.lines() {
if let Some(captures) = residual.captures(strip_toml_comment(line)) {
let var_name = captures
.get(1)
.expect(errors::CAPTURE_GROUP_MISSING)
.as_str();
return Err(crate::exit_code::RsconstructError::new(
crate::exit_code::RsconstructExitCode::ConfigError,
format!(
"Unresolved variable reference ${{{var_name}}}: a reference must be the entire quoted value (\"${{{var_name}}}\"), not embedded in a larger string"
),
).into());
}
}
Ok(())
}