use serde_json::Value as JsonValue;
use crate::error::{Result, RototoError};
pub const PRIMITIVE_VARIABLE_TYPES: &[&str] = &["bool", "int", "number", "string", "list"];
#[derive(Clone, Debug)]
pub struct VariableDefaultUpdate {
pub text: String,
pub before_literal: String,
pub after_literal: String,
}
struct ParsedDefault {
literal: String,
line_index: usize,
}
struct VariableParse {
description: Option<String>,
variable_type: Option<String>,
default: Option<ParsedDefault>,
}
pub fn update_primitive_variable_default(text: &str, value: &str) -> Result<VariableDefaultUpdate> {
let parsed = parse_variable_file(text);
let Some(variable_type) = parsed.variable_type.clone() else {
return Err(RototoError::new(
"Only primitive variables can be edited in this view.",
));
};
let Some(existing) = parsed.default else {
return Err(RototoError::new(
"Variable does not declare a resolve default.",
));
};
let after = parse_input_value(value, &variable_type)?;
let after_literal = format_toml_literal(&after, &variable_type);
let mut lines: Vec<String> = text.split(['\n']).map(str::to_owned).collect();
let line = &lines[existing.line_index];
let line = line.strip_suffix('\r').unwrap_or(line);
let prefix_len = line
.find('=')
.map(|eq| {
line[eq + 1..]
.char_indices()
.find(|(_, c)| !c.is_whitespace())
.map(|(at, _)| eq + 1 + at)
.unwrap_or(line.len())
})
.unwrap_or(0);
lines[existing.line_index] = format!("{}{}", &line[..prefix_len], after_literal);
Ok(VariableDefaultUpdate {
text: lines.join("\n"),
before_literal: existing.literal.clone(),
after_literal,
})
}
fn parse_variable_file(text: &str) -> VariableParse {
let mut parse = VariableParse {
description: None,
variable_type: None,
default: None,
};
let mut section = String::new();
for (line_index, line) in text.split('\n').enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if trimmed.starts_with('[') {
section = trimmed.to_owned();
continue;
}
if section.is_empty() {
if let Some(value) = quoted_field(trimmed, "type")
&& PRIMITIVE_VARIABLE_TYPES.contains(&value.as_str())
{
parse.variable_type = Some(value);
continue;
}
if let Some(rest) = field_literal(trimmed, "description")
&& rest.starts_with('"')
&& let Ok(JsonValue::String(description)) = serde_json::from_str(&rest)
{
parse.description = Some(description);
}
continue;
}
if section == "[resolve]" {
if let Some(literal) = field_literal(trimmed, "default") {
parse.default = Some(ParsedDefault {
literal,
line_index,
});
}
continue;
}
}
parse
}
fn key_value_line(line: &str) -> Option<(String, String)> {
let (key, value) = line.split_once('=')?;
let key = key.trim();
if key.is_empty()
|| !key
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-'))
{
return None;
}
let literal = value.trim();
if literal.is_empty() {
return None;
}
Some((key.to_owned(), literal.to_owned()))
}
fn field_literal(line: &str, field: &str) -> Option<String> {
let (key, literal) = key_value_line(line)?;
(key == field).then_some(literal)
}
fn quoted_field(line: &str, field: &str) -> Option<String> {
let literal = field_literal(line, field)?;
let inner = literal.strip_prefix('"')?.strip_suffix('"')?;
(!inner.contains('"')).then(|| inner.to_owned())
}
fn parse_input_value(value: &str, variable_type: &str) -> Result<JsonValue> {
let trimmed = value.trim();
match variable_type {
"bool" => match trimmed {
"true" => Ok(JsonValue::Bool(true)),
"false" => Ok(JsonValue::Bool(false)),
_ => Err(RototoError::new("Boolean values must be true or false.")),
},
"int" => trimmed
.parse::<i64>()
.map(JsonValue::from)
.map_err(|_| RototoError::new("Integer values must be whole numbers.")),
"number" => {
let number: f64 = trimmed
.parse()
.map_err(|_| RototoError::new("Number values must be finite."))?;
if !number.is_finite() {
return Err(RototoError::new("Number values must be finite."));
}
Ok(serde_json::Number::from_f64(number)
.map(JsonValue::Number)
.expect("finite numbers convert to JSON"))
}
"string" => Ok(JsonValue::String(trimmed.to_owned())),
"list" => {
let parsed: JsonValue = serde_json::from_str(trimmed)
.map_err(|_| RototoError::new("List values must be a JSON array."))?;
if !parsed.is_array() {
return Err(RototoError::new("List values must be a JSON array."));
}
Ok(parsed)
}
other => Err(RototoError::new(format!(
"unsupported primitive variable type: {other}"
))),
}
}
fn format_toml_literal(value: &JsonValue, variable_type: &str) -> String {
match variable_type {
"bool" => {
if value.as_bool().unwrap_or(false) {
"true".to_owned()
} else {
"false".to_owned()
}
}
"int" | "number" => match value {
JsonValue::Number(number) => number.to_string(),
other => other.to_string(),
},
_ => value.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
const VARIABLE: &str = r#"schema_version = 1
description = "Shows the launch banner"
type = "bool"
[resolve]
default = false
[[resolve.rule]]
when = 'qualifier["premium-users"]'
value = true
"#;
#[test]
fn updates_only_the_default_line() {
let update = update_primitive_variable_default(VARIABLE, "true").unwrap();
assert_eq!(update.before_literal, "false");
assert_eq!(update.after_literal, "true");
assert!(update.text.contains("default = true"));
assert!(update.text.contains("value = true"));
assert!(
update
.text
.contains("when = 'qualifier[\"premium-users\"]'")
);
let changed: Vec<(&str, &str)> = VARIABLE
.lines()
.zip(update.text.lines())
.filter(|(before, after)| before != after)
.collect();
assert_eq!(changed, vec![("default = false", "default = true")]);
}
#[test]
fn rejects_bad_inputs_per_type() {
assert!(update_primitive_variable_default(VARIABLE, "definitely").is_err());
let int_variable = VARIABLE
.replace("type = \"bool\"", "type = \"int\"")
.replace("default = false", "default = 4");
assert!(update_primitive_variable_default(&int_variable, "4.5").is_err());
let update = update_primitive_variable_default(&int_variable, "7").unwrap();
assert!(update.text.contains("default = 7"));
}
#[test]
fn non_primitive_variables_are_not_editable() {
let schema_variable = "schema_version = 1\nschema = \"x.json\"\n";
assert!(update_primitive_variable_default(schema_variable, "1").is_err());
}
}