use serde_json::Value as JsonValue;
use super::{MatchedBy, ResolveExpectation, ResolveInvocation};
#[derive(Clone, Copy, Debug, Default)]
pub enum ContextForm {
#[default]
Path,
Json,
}
pub fn render_command(
package_source: &str,
invocation: &ResolveInvocation,
form: ContextForm,
) -> String {
let mut parts = vec![
"rototo".to_owned(),
"resolve".to_owned(),
shell_quote(package_source),
];
parts.push(invocation.target.selector_flag().to_owned());
parts.push(shell_quote(invocation.target.id()));
for arg in context_args(&invocation.context, form) {
parts.push("--context".to_owned());
parts.push(shell_quote(&arg));
}
parts.join(" ")
}
pub fn render_comment(invocation: &ResolveInvocation) -> String {
match &invocation.expect {
ResolveExpectation::Variable { value, matched } => {
let value = compact(value);
match matched {
MatchedBy::Default => format!("# => {value} (default)"),
MatchedBy::Rule { index, condition } => {
format!("# => {value} (rule {index}: {condition})")
}
MatchedBy::Arm { allocation, arm } => {
format!("# => {value} (allocation {allocation}, arm {arm})")
}
}
}
}
}
fn context_args(context: &JsonValue, form: ContextForm) -> Vec<String> {
let empty = context
.as_object()
.map(serde_json::Map::is_empty)
.unwrap_or(true);
if empty {
return Vec::new();
}
match form {
ContextForm::Json => vec![compact(context)],
ContextForm::Path => {
let mut leaves = Vec::new();
collect_leaves(context, String::new(), &mut leaves);
leaves
.into_iter()
.map(|(path, value)| format!("{path}={}", value_token(&value)))
.collect()
}
}
}
fn collect_leaves(value: &JsonValue, path: String, out: &mut Vec<(String, JsonValue)>) {
match value {
JsonValue::Object(map) if !map.is_empty() => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
for key in keys {
let child_path = if path.is_empty() {
key.clone()
} else {
format!("{path}.{key}")
};
collect_leaves(&map[key], child_path, out);
}
}
_ => out.push((path, value.clone())),
}
}
fn value_token(value: &JsonValue) -> String {
if let JsonValue::String(raw) = value {
let round_trips_raw = match serde_json::from_str::<JsonValue>(raw) {
Ok(parsed) => &parsed == value,
Err(_) => true,
};
if round_trips_raw {
return raw.clone();
}
}
compact(value)
}
fn shell_quote(token: &str) -> String {
let safe = !token.is_empty()
&& token.bytes().all(|byte| {
byte.is_ascii_alphanumeric()
|| matches!(byte, b'_' | b'.' | b'/' | b'=' | b':' | b'-' | b'@' | b'+')
});
if safe {
token.to_owned()
} else {
format!("'{}'", token.replace('\'', "'\\''"))
}
}
fn compact(value: &JsonValue) -> String {
serde_json::to_string(value).unwrap_or_else(|_| "null".to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn parse_context_args(args: &[String]) -> JsonValue {
let mut root = serde_json::Map::new();
for arg in args {
if arg.trim_start().starts_with('{') {
let JsonValue::Object(map) = serde_json::from_str(arg).unwrap() else {
panic!("context json must be an object");
};
merge(&mut root, map);
continue;
}
let (path, value) = arg.split_once('=').expect("path=value");
let value =
serde_json::from_str(value).unwrap_or_else(|_| JsonValue::String(value.to_owned()));
insert_path(&mut root, path, value);
}
JsonValue::Object(root)
}
fn merge(
target: &mut serde_json::Map<String, JsonValue>,
source: serde_json::Map<String, JsonValue>,
) {
for (key, value) in source {
target.insert(key, value);
}
}
fn insert_path(object: &mut serde_json::Map<String, JsonValue>, path: &str, value: JsonValue) {
let mut segments = path.split('.').peekable();
let mut current = object;
while let Some(segment) = segments.next() {
if segments.peek().is_none() {
current.insert(segment.to_owned(), value);
return;
}
let entry = current
.entry(segment.to_owned())
.or_insert_with(|| JsonValue::Object(serde_json::Map::new()));
current = entry.as_object_mut().unwrap();
}
}
#[test]
fn shell_quote_leaves_safe_tokens_bare() {
assert_eq!(shell_quote("user.tier=premium"), "user.tier=premium");
assert_eq!(shell_quote("examples/basic"), "examples/basic");
assert_eq!(shell_quote("max_output_tokens"), "max_output_tokens");
}
#[test]
fn shell_quote_wraps_unsafe_tokens() {
assert_eq!(shell_quote("a b"), "'a b'");
assert_eq!(shell_quote("tags=[\"a\"]"), "'tags=[\"a\"]'");
assert_eq!(shell_quote("it's"), "'it'\\''s'");
assert_eq!(shell_quote(""), "''");
}
#[test]
fn value_token_keeps_plain_strings_raw() {
assert_eq!(value_token(&json!("premium")), "premium");
assert_eq!(value_token(&json!("fixture-other")), "fixture-other");
}
#[test]
fn value_token_json_encodes_ambiguous_strings() {
assert_eq!(value_token(&json!("250")), "\"250\"");
assert_eq!(value_token(&json!("true")), "\"true\"");
}
#[test]
fn value_token_json_encodes_non_strings() {
assert_eq!(value_token(&json!(250)), "250");
assert_eq!(value_token(&json!(true)), "true");
assert_eq!(value_token(&json!(["a", "b"])), "[\"a\",\"b\"]");
}
#[test]
fn path_args_round_trip_through_resolve_parser() {
let context = json!({
"user": {"tier": "premium", "session_count": 3},
"account": {"plan": "enterprise"},
"flags": ["a", "b"],
"literal_number_string": "250",
});
let args = context_args(&context, ContextForm::Path);
assert_eq!(
args,
vec![
"account.plan=enterprise".to_owned(),
"flags=[\"a\",\"b\"]".to_owned(),
"literal_number_string=\"250\"".to_owned(),
"user.session_count=3".to_owned(),
"user.tier=premium".to_owned(),
]
);
assert_eq!(parse_context_args(&args), context);
}
#[test]
fn json_form_round_trips_through_resolve_parser() {
let context = json!({"user": {"tier": "premium"}, "n": 3});
let args = context_args(&context, ContextForm::Json);
assert_eq!(args.len(), 1);
assert_eq!(parse_context_args(&args), context);
}
#[test]
fn empty_context_produces_no_args() {
assert!(context_args(&json!({}), ContextForm::Path).is_empty());
assert!(context_args(&json!({}), ContextForm::Json).is_empty());
}
}