use std::collections::BTreeMap;
use std::time::Duration;
use anyhow::{Result, bail};
use super::command::format_duration;
use super::value::Value;
pub fn escape_dsl_string(raw: &str) -> String {
let mut out = String::with_capacity(raw.len() + 2);
let mut chars = raw.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
'{' if chars.peek() == Some(&'{') => {
chars.next();
out.push_str("\\{{");
}
_ => out.push(ch),
}
}
out
}
pub fn render_dsl_literal(value: &Value) -> Result<String> {
match value {
_ if value.as_i64().is_some() => Ok(value.as_i64().unwrap_or(0).to_string()),
_ if value.as_f64().is_some() => render_float(value.as_f64().unwrap_or(0.0)),
_ if value.as_bool().is_some() => Ok(value.as_bool().unwrap_or(false).to_string()),
_ if value.as_str().is_some() => Ok(render_quoted(value.as_str().unwrap_or(""))),
_ if value.as_duration().is_some() => {
let text = format_duration(&value.as_duration().unwrap_or(Duration::from_secs(0)));
Ok(render_quoted(&text))
}
_ if value.as_list().is_some() => {
let mut items = Vec::new();
for item in value.as_list().unwrap_or(&Vec::new()) {
items.push(render_composite_item(item)?);
}
Ok(format!("[{}]", items.join(", ")))
}
_ if value.as_map().is_some() => {
let mut entries = Vec::new();
for (key, item) in value.as_map().unwrap_or(&BTreeMap::new()) {
entries.push(format!(
"{}: {}",
render_quoted(key),
render_composite_item(item)?
));
}
Ok(format!("{{{}}}", entries.join(", ")))
}
_ => bail!(
"value of type {} has no DSL literal form",
value.type_name()
),
}
}
fn render_float(raw: f64) -> Result<String> {
if !raw.is_finite() {
bail!("non-finite FLOAT has no DSL literal form");
}
let mut text = format!("{raw}");
if !text.contains('.') {
text.push_str(".0");
}
Ok(text)
}
pub fn render_quoted(raw: &str) -> String {
format!("\"{}\"", escape_dsl_string(raw))
}
fn render_composite_item(value: &Value) -> Result<String> {
if value.as_duration().is_some() {
let text = format_duration(&value.as_duration().unwrap_or(Duration::from_secs(0)));
return Ok(render_quoted(&text));
}
render_dsl_literal(value)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escape_round_trips_hostile_strings() {
for raw in [
"plain",
"quote \" inside",
"back\\slash",
"line\nbreak",
"return\r carriage",
"tab\there",
"template {{ $x }} here",
"semi; brace} hash#",
"\\{{ already }}",
] {
let rendered = render_quoted(raw);
assert!(rendered.starts_with('"') && rendered.ends_with('"'));
let inner = &rendered[1..rendered.len() - 1];
let stripped = inner.replace("\\{{", "");
assert!(
!stripped.contains("{{"),
"unescaped template pair in {rendered:?}"
);
}
}
#[test]
fn escape_table_inverts_expand_string() {
let rendered = escape_dsl_string("a\"b\\c\nd\re\tf{{g}}");
assert_eq!(rendered, "a\\\"b\\\\c\\nd\\re\\tf\\{{g}}");
}
#[test]
fn render_float_always_carries_a_point() {
assert_eq!(render_float(3.0).unwrap(), "3.0");
assert!(render_float(f64::NAN).is_err());
assert!(render_float(f64::INFINITY).is_err());
}
#[test]
fn render_rejects_handles_and_paths() {
for value in [
Value::pipe_fresh(),
Value::handle(7),
Value::path("/tmp/x".into()),
] {
assert!(render_dsl_literal(&value).is_err());
}
}
#[test]
fn render_scalars_and_composites() {
assert_eq!(render_dsl_literal(&Value::int(7)).unwrap(), "7");
assert_eq!(
render_dsl_literal(&Value::string("hi".to_string())).unwrap(),
"\"hi\""
);
assert_eq!(render_dsl_literal(&Value::bool(true)).unwrap(), "true");
let list = Value::list(vec![Value::int(1), Value::string("a\"b".to_string())]);
assert_eq!(render_dsl_literal(&list).unwrap(), "[1, \"a\\\"b\"]");
let mut entries = BTreeMap::new();
entries.insert("k".to_string(), Value::int(2));
assert_eq!(
render_dsl_literal(&Value::map(entries)).unwrap(),
"{\"k\": 2}"
);
}
}