use rustc_hash::FxHashMap;
use serde::de::{self, Deserializer, MapAccess, Visitor};
use serde::Deserialize;
use serde_json::Value;
use std::fmt;
use crate::error::NikaError;
use super::transform::TransformExpr;
use super::types::{BindingPath, BindingType};
pub type BindingSpec = FxHashMap<String, BindingEntry>;
#[derive(Debug, Clone, PartialEq)]
pub struct BindingEntry {
pub path: String,
pub default: Option<Value>,
pub lazy: bool,
}
impl BindingEntry {
pub fn new(path: impl Into<String>) -> Self {
Self {
path: path.into(),
default: None,
lazy: false,
}
}
pub fn with_default(path: impl Into<String>, default: Value) -> Self {
Self {
path: path.into(),
default: Some(default),
lazy: false,
}
}
pub fn new_lazy(path: impl Into<String>) -> Self {
Self {
path: path.into(),
default: None,
lazy: true,
}
}
pub fn lazy_with_default(path: impl Into<String>, default: Value) -> Self {
Self {
path: path.into(),
default: Some(default),
lazy: true,
}
}
pub fn is_lazy(&self) -> bool {
self.lazy
}
pub fn task_id(&self) -> &str {
self.path.split('.').next().unwrap_or(&self.path)
}
#[inline]
pub fn normalize_path(path: &str) -> &str {
path.strip_prefix('$').unwrap_or(path)
}
}
pub fn parse_binding_entry(s: &str) -> Result<BindingEntry, NikaError> {
let s = s.trim();
if s.is_empty() {
return Err(NikaError::InvalidPath {
path: String::new(),
});
}
match find_operator_outside_quotes(s, "??") {
Some(idx) => {
let path = s[..idx].trim();
if path.is_empty() {
return Err(NikaError::InvalidPath {
path: s.to_string(),
});
}
let default_str = s[idx + 2..].trim();
let default =
serde_json::from_str(default_str).map_err(|e| NikaError::InvalidDefault {
raw: default_str.to_string(),
reason: e.to_string(),
})?;
Ok(BindingEntry {
path: path.to_string(),
default: Some(default),
lazy: false,
})
}
None => Ok(BindingEntry {
path: s.to_string(),
default: None,
lazy: false,
}),
}
}
fn find_operator_outside_quotes(s: &str, op: &str) -> Option<usize> {
let mut in_quotes = false;
let mut escape_next = false;
let mut byte_pos = 0;
for ch in s.chars() {
if escape_next {
escape_next = false;
} else if ch == '\\' {
escape_next = true;
} else if ch == '"' {
in_quotes = !in_quotes;
} else if !in_quotes && s[byte_pos..].starts_with(op) {
return Some(byte_pos);
}
byte_pos += ch.len_utf8();
}
None
}
impl<'de> Deserialize<'de> for BindingEntry {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_any(BindingEntryVisitor)
}
}
struct BindingEntryVisitor;
impl<'de> Visitor<'de> for BindingEntryVisitor {
type Value = BindingEntry;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter
.write_str("a string 'task.path [?? default]' or an object {path, lazy?, default?}")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
let mut entry = parse_binding_entry(value).map_err(|e| de::Error::custom(e.to_string()))?;
entry.path = BindingEntry::normalize_path(&entry.path).to_string();
Ok(entry)
}
fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
where
M: MapAccess<'de>,
{
let mut path: Option<String> = None;
let mut lazy: Option<bool> = None;
let mut default: Option<Value> = None;
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"path" => {
if path.is_some() {
return Err(de::Error::duplicate_field("path"));
}
path = Some(map.next_value()?);
}
"lazy" => {
if lazy.is_some() {
return Err(de::Error::duplicate_field("lazy"));
}
lazy = Some(map.next_value()?);
}
"default" => {
if default.is_some() {
return Err(de::Error::duplicate_field("default"));
}
default = Some(map.next_value()?);
}
_ => {
let _ = map.next_value::<de::IgnoredAny>()?;
}
}
}
let path = path.ok_or_else(|| de::Error::missing_field("path"))?;
let path = BindingEntry::normalize_path(&path).to_string();
Ok(BindingEntry {
path,
default,
lazy: lazy.unwrap_or(false),
})
}
}
#[derive(Debug, Clone)]
pub struct WithEntry {
pub source: BindingPath,
pub binding_type: BindingType,
pub default: Option<Value>,
pub lazy: bool,
pub transform: Option<TransformExpr>,
}
pub type WithSpec = FxHashMap<String, WithEntry>;
#[derive(Debug, Clone, PartialEq)]
pub struct WithEntryParseError {
pub input: String,
pub reason: String,
}
impl fmt::Display for WithEntryParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"[NIKA-155] WithEntry parse error in '{}': {}",
self.input, self.reason
)
}
}
impl std::error::Error for WithEntryParseError {}
impl WithEntry {
pub fn simple(source: BindingPath) -> Self {
Self {
source,
binding_type: BindingType::default(),
default: None,
lazy: false,
transform: None,
}
}
pub fn with_default(source: BindingPath, default: Value) -> Self {
Self {
source,
binding_type: BindingType::default(),
default: Some(default),
lazy: false,
transform: None,
}
}
pub fn task_id(&self) -> Option<&str> {
use super::types::BindingSource;
match &self.source.source {
BindingSource::Task(id) => Some(id),
_ => None,
}
}
pub fn is_lazy(&self) -> bool {
self.lazy
}
}
pub fn parse_with_entry(input: &str) -> Result<WithEntry, WithEntryParseError> {
let input_trimmed = input.trim();
if input_trimmed.is_empty() {
return Err(WithEntryParseError {
input: input.to_string(),
reason: "empty input".to_string(),
});
}
let (path_and_transforms, default_value) = split_default(input_trimmed)?;
if path_and_transforms.is_empty() {
return Err(WithEntryParseError {
input: input.to_string(),
reason: "empty path before '??'".to_string(),
});
}
let (path_str, transform_str) = split_transforms(path_and_transforms);
let path_str = path_str.trim();
if path_str.is_empty() {
return Err(WithEntryParseError {
input: input.to_string(),
reason: "empty path".to_string(),
});
}
let source = BindingPath::parse(path_str).map_err(|e| WithEntryParseError {
input: input.to_string(),
reason: e.reason,
})?;
let transform = if let Some(t_str) = transform_str {
let t_str = t_str.trim();
if t_str.is_empty() {
return Err(WithEntryParseError {
input: input.to_string(),
reason: "empty transform after '|'".to_string(),
});
}
Some(
TransformExpr::parse(t_str).map_err(|e| WithEntryParseError {
input: input.to_string(),
reason: e.reason,
})?,
)
} else {
None
};
let default = match default_value {
Some(d_str) => {
let d_str = d_str.trim();
if d_str.is_empty() {
return Err(WithEntryParseError {
input: input.to_string(),
reason: "empty default value after '??'".to_string(),
});
}
let val: Value = serde_json::from_str(d_str).map_err(|e| WithEntryParseError {
input: input.to_string(),
reason: format!("invalid default JSON: {e}"),
})?;
Some(val)
}
None => None,
};
Ok(WithEntry {
source,
binding_type: BindingType::default(),
default,
lazy: false,
transform,
})
}
fn split_default(s: &str) -> Result<(&str, Option<&str>), WithEntryParseError> {
let mut in_quotes = false;
let mut escape_next = false;
let mut paren_depth: u32 = 0;
let mut last_default_pos: Option<usize> = None;
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if escape_next {
escape_next = false;
i += 1;
continue;
}
match bytes[i] {
b'\\' => {
escape_next = true;
}
b'"' => {
in_quotes = !in_quotes;
}
b'(' if !in_quotes => {
paren_depth = paren_depth.saturating_add(1);
}
b')' if !in_quotes => {
paren_depth = paren_depth.saturating_sub(1);
}
b'?' if !in_quotes && paren_depth == 0 => {
if i + 1 < bytes.len() && bytes[i + 1] == b'?' {
last_default_pos = Some(i);
i += 2; continue;
}
}
_ => {}
}
i += 1;
}
match last_default_pos {
Some(pos) => {
let path_part = &s[..pos].trim_end();
let default_part = &s[pos + 2..].trim_start();
Ok((path_part, Some(default_part)))
}
None => Ok((s, None)),
}
}
fn split_transforms(s: &str) -> (&str, Option<&str>) {
let mut in_quotes = false;
let mut escape_next = false;
let mut paren_depth: u32 = 0;
let bytes = s.as_bytes();
for (i, &b) in bytes.iter().enumerate() {
if escape_next {
escape_next = false;
continue;
}
match b {
b'\\' => escape_next = true,
b'"' => in_quotes = !in_quotes,
b'(' if !in_quotes => paren_depth = paren_depth.saturating_add(1),
b')' if !in_quotes => paren_depth = paren_depth.saturating_sub(1),
b'|' if !in_quotes && paren_depth == 0 => {
return (&s[..i], Some(&s[i + 1..]));
}
_ => {}
}
}
(s, None)
}
impl<'de> Deserialize<'de> for WithEntry {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_any(WithEntryVisitor)
}
}
struct WithEntryVisitor;
impl<'de> Visitor<'de> for WithEntryVisitor {
type Value = WithEntry;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str(
"a string '$path | transform ?? default' or an object \
{ from, type?, transform?, default?, lazy? }",
)
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
parse_with_entry(value).map_err(|e| de::Error::custom(e.to_string()))
}
fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
where
M: MapAccess<'de>,
{
#[derive(Deserialize)]
struct WithEntryObject {
from: String,
#[serde(rename = "type", default)]
binding_type: BindingType,
#[serde(default)]
transform: Option<String>,
#[serde(default)]
default: Option<Value>,
#[serde(default)]
lazy: bool,
}
let obj = WithEntryObject::deserialize(de::value::MapAccessDeserializer::new(map))?;
let source = BindingPath::parse(&obj.from)
.map_err(|e| de::Error::custom(format!("[NIKA-155] invalid 'from' path: {e}")))?;
let transform = match obj.transform {
Some(ref t_str) if !t_str.trim().is_empty() => Some(
TransformExpr::parse(t_str.trim())
.map_err(|e| de::Error::custom(format!("[NIKA-155] invalid transform: {e}")))?,
),
_ => None,
};
Ok(WithEntry {
source,
binding_type: obj.binding_type,
default: obj.default,
lazy: obj.lazy,
transform,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::binding::transform::TransformOp;
use crate::binding::types::BindingSource;
use crate::serde_yaml;
use serde_json::json;
#[test]
fn parse_simple_path() {
let entry = parse_binding_entry("weather.summary").unwrap();
assert_eq!(entry.path, "weather.summary");
assert_eq!(entry.default, None);
}
#[test]
fn parse_simple_task_only() {
let entry = parse_binding_entry("weather").unwrap();
assert_eq!(entry.path, "weather");
assert_eq!(entry.default, None);
}
#[test]
fn parse_nested_path() {
let entry = parse_binding_entry("weather.data.temperature.celsius").unwrap();
assert_eq!(entry.path, "weather.data.temperature.celsius");
assert_eq!(entry.default, None);
}
#[test]
fn parse_with_default_number() {
let entry = parse_binding_entry("x.y ?? 0").unwrap();
assert_eq!(entry.path, "x.y");
assert_eq!(entry.default, Some(json!(0)));
}
#[test]
fn parse_with_default_negative_number() {
let entry = parse_binding_entry("score ?? -1").unwrap();
assert_eq!(entry.path, "score");
assert_eq!(entry.default, Some(json!(-1)));
}
#[test]
fn parse_with_default_float() {
let entry = parse_binding_entry("rate ?? 0.5").unwrap();
assert_eq!(entry.path, "rate");
assert_eq!(entry.default, Some(json!(0.5)));
}
#[test]
fn parse_with_default_string() {
let entry = parse_binding_entry(r#"x.y ?? "Anon""#).unwrap();
assert_eq!(entry.path, "x.y");
assert_eq!(entry.default, Some(json!("Anon")));
}
#[test]
fn parse_with_default_empty_string() {
let entry = parse_binding_entry(r#"name ?? """#).unwrap();
assert_eq!(entry.path, "name");
assert_eq!(entry.default, Some(json!("")));
}
#[test]
fn parse_with_default_bool_true() {
let entry = parse_binding_entry("enabled ?? true").unwrap();
assert_eq!(entry.path, "enabled");
assert_eq!(entry.default, Some(json!(true)));
}
#[test]
fn parse_with_default_bool_false() {
let entry = parse_binding_entry("enabled ?? false").unwrap();
assert_eq!(entry.path, "enabled");
assert_eq!(entry.default, Some(json!(false)));
}
#[test]
fn parse_with_default_null() {
let entry = parse_binding_entry("value ?? null").unwrap();
assert_eq!(entry.path, "value");
assert_eq!(entry.default, Some(json!(null)));
}
#[test]
fn parse_with_default_object() {
let entry = parse_binding_entry(r#"x ?? {"a": 1, "b": 2}"#).unwrap();
assert_eq!(entry.path, "x");
assert_eq!(entry.default, Some(json!({"a": 1, "b": 2})));
}
#[test]
fn parse_with_default_array() {
let entry = parse_binding_entry(r#"tags ?? ["untagged"]"#).unwrap();
assert_eq!(entry.path, "tags");
assert_eq!(entry.default, Some(json!(["untagged"])));
}
#[test]
fn parse_with_default_nested_object() {
let entry = parse_binding_entry(r#"cfg ?? {"debug": false, "nested": {"a": 1}}"#).unwrap();
assert_eq!(entry.path, "cfg");
assert_eq!(
entry.default,
Some(json!({"debug": false, "nested": {"a": 1}}))
);
}
#[test]
fn parse_quotes_in_default() {
let entry = parse_binding_entry(r#"x ?? "What?? Really??""#).unwrap();
assert_eq!(entry.path, "x");
assert_eq!(entry.default, Some(json!("What?? Really??")));
}
#[test]
fn parse_escaped_quotes_in_default() {
let entry = parse_binding_entry(r#"x ?? "He said \"hello\"""#).unwrap();
assert_eq!(entry.path, "x");
assert_eq!(entry.default, Some(json!("He said \"hello\"")));
}
#[test]
fn parse_with_whitespace() {
let entry = parse_binding_entry(" weather.summary ").unwrap();
assert_eq!(entry.path, "weather.summary");
}
#[test]
fn parse_with_whitespace_around_operator() {
let entry = parse_binding_entry("x ?? 0").unwrap();
assert_eq!(entry.path, "x");
assert_eq!(entry.default, Some(json!(0)));
}
#[test]
fn parse_reject_unquoted_string() {
let result = parse_binding_entry("x ?? Anonymous");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("NIKA-056"));
}
#[test]
fn parse_reject_empty_path() {
let result = parse_binding_entry("");
assert!(result.is_err());
}
#[test]
fn parse_reject_only_operator() {
let result = parse_binding_entry("??");
assert!(result.is_err());
}
#[test]
fn parse_reject_empty_path_with_default() {
let result = parse_binding_entry("?? 0");
assert!(result.is_err());
}
#[test]
fn parse_reject_invalid_json_default() {
let result = parse_binding_entry(r#"x ?? {"a": 1"#);
assert!(result.is_err());
}
#[test]
fn task_id_simple() {
let entry = BindingEntry::new("weather");
assert_eq!(entry.task_id(), "weather");
}
#[test]
fn task_id_with_path() {
let entry = BindingEntry::new("weather.summary");
assert_eq!(entry.task_id(), "weather");
}
#[test]
fn task_id_with_nested_path() {
let entry = BindingEntry::new("weather.data.temp.celsius");
assert_eq!(entry.task_id(), "weather");
}
#[test]
fn yaml_parse_simple() {
let yaml = "forecast: weather.summary";
let spec: BindingSpec = serde_yaml::from_str(yaml).unwrap();
let entry = spec.get("forecast").unwrap();
assert_eq!(entry.path, "weather.summary");
assert_eq!(entry.default, None);
}
#[test]
fn yaml_parse_with_default() {
let yaml = r#"temp: weather.temp ?? 20"#;
let spec: BindingSpec = serde_yaml::from_str(yaml).unwrap();
let entry = spec.get("temp").unwrap();
assert_eq!(entry.path, "weather.temp");
assert_eq!(entry.default, Some(json!(20)));
}
#[test]
fn yaml_parse_multiple_entries() {
let yaml = r#"
forecast: weather.summary
temp: weather.temp ?? 20
name: user.name ?? "Anonymous"
"#;
let spec: BindingSpec = serde_yaml::from_str(yaml).unwrap();
let forecast = spec.get("forecast").unwrap();
assert_eq!(forecast.path, "weather.summary");
assert_eq!(forecast.default, None);
let temp = spec.get("temp").unwrap();
assert_eq!(temp.path, "weather.temp");
assert_eq!(temp.default, Some(json!(20)));
let name = spec.get("name").unwrap();
assert_eq!(name.path, "user.name");
assert_eq!(name.default, Some(json!("Anonymous")));
}
#[test]
fn yaml_parse_complex_defaults() {
let yaml = r#"
cfg: 'settings ?? {"debug": false}'
tags: 'meta.tags ?? ["default"]'
"#;
let spec: BindingSpec = serde_yaml::from_str(yaml).unwrap();
let cfg = spec.get("cfg").unwrap();
assert_eq!(cfg.default, Some(json!({"debug": false})));
let tags = spec.get("tags").unwrap();
assert_eq!(tags.default, Some(json!(["default"])));
}
#[test]
fn find_op_simple() {
assert_eq!(find_operator_outside_quotes("a ?? b", "??"), Some(2));
}
#[test]
fn find_op_no_match() {
assert_eq!(find_operator_outside_quotes("a.b.c", "??"), None);
}
#[test]
fn find_op_inside_quotes_ignored() {
let s = r#"x ?? "What?? Really??""#;
assert_eq!(find_operator_outside_quotes(s, "??"), Some(2));
}
#[test]
fn find_op_only_inside_quotes() {
let s = r#""a ?? b""#;
assert_eq!(find_operator_outside_quotes(s, "??"), None);
}
#[test]
fn find_op_multiple_operators() {
let s = "a ?? b ?? c";
assert_eq!(find_operator_outside_quotes(s, "??"), Some(2));
}
#[test]
fn find_op_with_escaped_quote() {
let s = r#"x ?? "He said \"??\"""#;
assert_eq!(find_operator_outside_quotes(s, "??"), Some(2));
}
#[test]
fn test_normalize_path_strips_dollar_prefix() {
assert_eq!(BindingEntry::normalize_path("$task1"), "task1");
assert_eq!(BindingEntry::normalize_path("task1"), "task1");
assert_eq!(BindingEntry::normalize_path("$my_task"), "my_task");
assert_eq!(BindingEntry::normalize_path("task.field"), "task.field");
assert_eq!(BindingEntry::normalize_path("$task.field"), "task.field");
}
#[test]
fn test_binding_entry_deserialize_normalizes_dollar_prefix_shorthand() {
let entry: BindingEntry = serde_yaml::from_str("\"$task1\"").unwrap();
assert_eq!(entry.path, "task1");
let entry: BindingEntry = serde_yaml::from_str("\"task1\"").unwrap();
assert_eq!(entry.path, "task1");
}
#[test]
fn test_binding_entry_deserialize_normalizes_dollar_prefix_full_form() {
let entry: BindingEntry = serde_yaml::from_str(
r#"
path: "$my_task"
default: "fallback"
"#,
)
.unwrap();
assert_eq!(entry.path, "my_task");
assert_eq!(
entry.default.as_ref().map(|v| v.as_str()),
Some(Some("fallback"))
);
let entry: BindingEntry = serde_yaml::from_str(
r#"
path: "my_task"
lazy: true
"#,
)
.unwrap();
assert_eq!(entry.path, "my_task");
assert!(entry.lazy);
}
#[test]
fn test_normalize_path_edge_cases() {
assert_eq!(BindingEntry::normalize_path("$$task"), "$task");
assert_eq!(BindingEntry::normalize_path("$$$task"), "$$task");
assert_eq!(BindingEntry::normalize_path("ta$sk"), "ta$sk");
assert_eq!(BindingEntry::normalize_path("task$"), "task$");
assert_eq!(BindingEntry::normalize_path("ta$sk$"), "ta$sk$");
assert_eq!(
BindingEntry::normalize_path("$task.field.subfield"),
"task.field.subfield"
);
assert_eq!(
BindingEntry::normalize_path("$task.nested.deep.path"),
"task.nested.deep.path"
);
assert_eq!(BindingEntry::normalize_path(""), "");
assert_eq!(BindingEntry::normalize_path("$"), "");
assert_eq!(BindingEntry::normalize_path("$$"), "$");
assert_eq!(BindingEntry::normalize_path("$."), ".");
assert_eq!(BindingEntry::normalize_path("$.."), "..");
assert_eq!(BindingEntry::normalize_path(".task"), ".task");
assert_eq!(BindingEntry::normalize_path("$.task"), ".task");
assert_eq!(BindingEntry::normalize_path("$résultat"), "résultat");
assert_eq!(BindingEntry::normalize_path("$задача"), "задача");
assert_eq!(BindingEntry::normalize_path("$ task"), " task");
assert_eq!(BindingEntry::normalize_path("$task "), "task ");
}
#[test]
fn test_binding_entry_deserialize_nested_field_access() {
let entry: BindingEntry = serde_yaml::from_str("\"$research.summary.title\"").unwrap();
assert_eq!(entry.path, "research.summary.title");
let entry: BindingEntry = serde_yaml::from_str(
r#"
path: "$agent_result.response.data.items"
lazy: true
"#,
)
.unwrap();
assert_eq!(entry.path, "agent_result.response.data.items");
assert!(entry.lazy);
}
#[test]
fn test_binding_entry_deserialize_multiple_dollar_signs() {
let entry: BindingEntry = serde_yaml::from_str("\"$$task\"").unwrap();
assert_eq!(entry.path, "$task");
let entry: BindingEntry = serde_yaml::from_str("\"$$$triple\"").unwrap();
assert_eq!(entry.path, "$$triple");
let entry: BindingEntry = serde_yaml::from_str(
r#"
path: "$$escaped_var"
"#,
)
.unwrap();
assert_eq!(entry.path, "$escaped_var");
}
#[test]
fn test_binding_entry_deserialize_dollar_in_middle() {
let entry: BindingEntry = serde_yaml::from_str("\"task$name\"").unwrap();
assert_eq!(entry.path, "task$name");
let entry: BindingEntry = serde_yaml::from_str("\"$task$name\"").unwrap();
assert_eq!(entry.path, "task$name");
let entry: BindingEntry = serde_yaml::from_str(
r#"
path: "result$2"
"#,
)
.unwrap();
assert_eq!(entry.path, "result$2");
}
#[test]
fn test_binding_entry_deserialize_special_characters() {
let entry: BindingEntry = serde_yaml::from_str("\"$task_123\"").unwrap();
assert_eq!(entry.path, "task_123");
let entry: BindingEntry = serde_yaml::from_str("\"$_private_task\"").unwrap();
assert_eq!(entry.path, "_private_task");
let entry: BindingEntry = serde_yaml::from_str("\"$task-name\"").unwrap();
assert_eq!(entry.path, "task-name");
let entry: BindingEntry = serde_yaml::from_str("\"$task_1-result.field_2\"").unwrap();
assert_eq!(entry.path, "task_1-result.field_2");
}
#[test]
fn test_binding_entry_deserialize_with_all_options() {
let entry: BindingEntry = serde_yaml::from_str(
r#"
path: "$complex_task.nested.value"
default: "default_value"
lazy: true
"#,
)
.unwrap();
assert_eq!(entry.path, "complex_task.nested.value");
assert_eq!(
entry.default.as_ref().map(|v| v.as_str()),
Some(Some("default_value"))
);
assert!(entry.lazy);
}
#[test]
fn test_binding_entry_equivalence_with_and_without_dollar() {
let with_dollar: BindingEntry = serde_yaml::from_str("\"$my_task\"").unwrap();
let without_dollar: BindingEntry = serde_yaml::from_str("\"my_task\"").unwrap();
assert_eq!(with_dollar.path, without_dollar.path);
assert_eq!(with_dollar.default, without_dollar.default);
assert_eq!(with_dollar.lazy, without_dollar.lazy);
}
#[test]
fn test_binding_entry_deserialize_real_workflow_patterns() {
let entry: BindingEntry = serde_yaml::from_str("\"$get_context\"").unwrap();
assert_eq!(entry.path, "get_context");
let entry: BindingEntry = serde_yaml::from_str("\"$generate.content\"").unwrap();
assert_eq!(entry.path, "generate.content");
let entry: BindingEntry =
serde_yaml::from_str("\"$research_agent.findings.summary\"").unwrap();
assert_eq!(entry.path, "research_agent.findings.summary");
let entry: BindingEntry = serde_yaml::from_str(
r#"
path: "$optional_step.result"
default: null
lazy: true
"#,
)
.unwrap();
assert_eq!(entry.path, "optional_step.result");
assert_eq!(entry.default, Some(serde_json::Value::Null));
assert!(entry.lazy);
}
#[test]
fn with_parse_simple() {
let entry = parse_with_entry("$step1").unwrap();
assert_eq!(entry.source, BindingPath::parse("$step1").unwrap());
assert_eq!(entry.default, None);
assert_eq!(entry.transform, None);
assert!(!entry.lazy);
assert_eq!(entry.binding_type, BindingType::Any);
}
#[test]
fn with_parse_field_access() {
let entry = parse_with_entry("$step1.output").unwrap();
assert_eq!(entry.source, BindingPath::parse("$step1.output").unwrap());
assert_eq!(entry.default, None);
assert_eq!(entry.transform, None);
}
#[test]
fn with_parse_deep_path() {
let entry = parse_with_entry("$step1.data.items[0].name").unwrap();
assert_eq!(
entry.source,
BindingPath::parse("$step1.data.items[0].name").unwrap()
);
}
#[test]
fn with_parse_default_string() {
let entry = parse_with_entry(r#"$step1 ?? "fallback""#).unwrap();
assert_eq!(entry.source, BindingPath::parse("$step1").unwrap());
assert_eq!(entry.default, Some(json!("fallback")));
assert_eq!(entry.transform, None);
}
#[test]
fn with_parse_default_number() {
let entry = parse_with_entry("$step1 ?? 42").unwrap();
assert_eq!(entry.default, Some(json!(42)));
}
#[test]
fn with_parse_default_float() {
let entry = parse_with_entry("$step1.score ?? 0.5").unwrap();
assert_eq!(entry.default, Some(json!(0.5)));
}
#[test]
fn with_parse_default_bool() {
let entry = parse_with_entry("$step1.enabled ?? true").unwrap();
assert_eq!(entry.default, Some(json!(true)));
}
#[test]
fn with_parse_default_null() {
let entry = parse_with_entry("$step1.val ?? null").unwrap();
assert_eq!(entry.default, Some(json!(null)));
}
#[test]
fn with_parse_default_array() {
let entry = parse_with_entry("$step1 ?? []").unwrap();
assert_eq!(entry.default, Some(json!([])));
}
#[test]
fn with_parse_default_object() {
let entry = parse_with_entry(r#"$step1 ?? {"key": "val"}"#).unwrap();
assert_eq!(entry.default, Some(json!({"key": "val"})));
}
#[test]
fn with_parse_transform_single() {
let entry = parse_with_entry("$step1 | upper").unwrap();
assert_eq!(entry.source, BindingPath::parse("$step1").unwrap());
assert!(entry.transform.is_some());
let t = entry.transform.unwrap();
assert_eq!(t.ops.len(), 1);
assert_eq!(t.ops[0], TransformOp::Upper);
}
#[test]
fn with_parse_transform_chain() {
let entry = parse_with_entry("$step1.items | sort | unique").unwrap();
let t = entry.transform.unwrap();
assert_eq!(t.ops.len(), 2);
assert_eq!(t.ops[0], TransformOp::Sort);
assert_eq!(t.ops[1], TransformOp::Unique);
}
#[test]
fn with_parse_transform_with_args() {
let entry = parse_with_entry("$step1.items | first(3)").unwrap();
let t = entry.transform.unwrap();
assert_eq!(t.ops.len(), 1);
assert_eq!(t.ops[0], TransformOp::FirstN(3));
}
#[test]
fn with_parse_transform_and_default() {
let entry = parse_with_entry("$step1.items | length ?? 0").unwrap();
assert!(entry.transform.is_some());
let t = entry.transform.unwrap();
assert_eq!(t.ops.len(), 1);
assert_eq!(t.ops[0], TransformOp::Length);
assert_eq!(entry.default, Some(json!(0)));
}
#[test]
fn with_parse_full_chain_and_default() {
let entry = parse_with_entry(r#"$step1.items | sort | first(3) ?? []"#).unwrap();
let t = entry.transform.unwrap();
assert_eq!(t.ops.len(), 2);
assert_eq!(t.ops[0], TransformOp::Sort);
assert_eq!(t.ops[1], TransformOp::FirstN(3));
assert_eq!(entry.default, Some(json!([])));
}
#[test]
fn with_parse_context_ref() {
let entry = parse_with_entry("$context.files.brand").unwrap();
match &entry.source.source {
BindingSource::Context(path) => assert_eq!(path.as_ref(), "files.brand"),
_ => panic!("expected Context source"),
}
}
#[test]
fn with_parse_input_ref() {
let entry = parse_with_entry("$inputs.locale").unwrap();
match &entry.source.source {
BindingSource::Input(path) => assert_eq!(path.as_ref(), "locale"),
_ => panic!("expected Input source"),
}
}
#[test]
fn with_parse_env_ref() {
let entry = parse_with_entry("$env.API_URL").unwrap();
match &entry.source.source {
BindingSource::Env(path) => assert_eq!(path.as_ref(), "API_URL"),
_ => panic!("expected Env source"),
}
}
#[test]
fn with_parse_whitespace_tolerance() {
let entry = parse_with_entry(" $step1 | upper ").unwrap();
assert_eq!(entry.source, BindingPath::parse("$step1").unwrap());
let t = entry.transform.unwrap();
assert_eq!(t.ops[0], TransformOp::Upper);
}
#[test]
fn with_parse_whitespace_around_default() {
let entry = parse_with_entry("$step1 ?? 42").unwrap();
assert_eq!(entry.default, Some(json!(42)));
}
#[test]
fn with_parse_empty_string_error() {
let result = parse_with_entry("");
assert!(result.is_err());
assert!(result.unwrap_err().reason.contains("empty"));
}
#[test]
fn with_parse_no_dollar_error() {
let result = parse_with_entry("step1");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.reason.contains("must start with '$'"));
}
#[test]
fn with_parse_pipe_only_error() {
let result = parse_with_entry("| upper");
assert!(result.is_err());
}
#[test]
fn with_parse_default_only_error() {
let result = parse_with_entry("?? 42");
assert!(result.is_err());
}
#[test]
fn with_parse_trailing_pipe_error() {
let result = parse_with_entry("$step1 |");
assert!(result.is_err());
assert!(result.unwrap_err().reason.contains("empty transform"));
}
#[test]
fn with_parse_invalid_json_default_error() {
let result = parse_with_entry(r#"$step1 ?? {broken"#);
assert!(result.is_err());
assert!(result.unwrap_err().reason.contains("invalid default JSON"));
}
#[test]
fn with_parse_unquoted_string_default_error() {
let result = parse_with_entry("$step1 ?? Anonymous");
assert!(result.is_err());
}
#[test]
fn with_parse_empty_default_error() {
let result = parse_with_entry("$step1 ??");
assert!(result.is_err());
assert!(result.unwrap_err().reason.contains("empty default"));
}
#[test]
fn with_parse_unknown_transform_error() {
let result = parse_with_entry("$step1 | nonexistent_transform");
assert!(result.is_err());
}
#[test]
fn with_parse_default_inside_parens_ignored() {
let entry = parse_with_entry(r#"$step1 | default("a ?? b")"#).unwrap();
assert!(entry.transform.is_some());
assert_eq!(entry.default, None); }
#[test]
fn with_parse_default_after_transform_with_inner_qq() {
let entry = parse_with_entry(r#"$step1 | default("a ?? b") ?? "fallback""#).unwrap();
assert!(entry.transform.is_some());
assert_eq!(entry.default, Some(json!("fallback")));
}
#[test]
fn with_entry_task_id() {
let entry = parse_with_entry("$step1.data.name").unwrap();
assert_eq!(entry.task_id(), Some("step1"));
}
#[test]
fn with_entry_task_id_context() {
let entry = parse_with_entry("$context.files.brand").unwrap();
assert_eq!(entry.task_id(), None);
}
#[test]
fn with_entry_simple_constructor() {
let path = BindingPath::parse("$step1.data").unwrap();
let entry = WithEntry::simple(path.clone());
assert_eq!(entry.source, path);
assert_eq!(entry.default, None);
assert!(!entry.lazy);
assert_eq!(entry.transform, None);
}
#[test]
fn with_entry_with_default_constructor() {
let path = BindingPath::parse("$step1").unwrap();
let entry = WithEntry::with_default(path.clone(), json!(42));
assert_eq!(entry.source, path);
assert_eq!(entry.default, Some(json!(42)));
}
#[test]
fn with_deser_string_simple() {
let entry: WithEntry = serde_yaml::from_str("\"$step1\"").unwrap();
assert_eq!(entry.source, BindingPath::parse("$step1").unwrap());
}
#[test]
fn with_deser_string_with_transform() {
let entry: WithEntry = serde_yaml::from_str("\"$step1.name | upper\"").unwrap();
assert_eq!(entry.source, BindingPath::parse("$step1.name").unwrap());
let t = entry.transform.unwrap();
assert_eq!(t.ops[0], TransformOp::Upper);
}
#[test]
fn with_deser_string_with_default() {
let entry: WithEntry = serde_yaml::from_str(r#""$step1 ?? 42""#).unwrap();
assert_eq!(entry.default, Some(json!(42)));
}
#[test]
fn with_deser_object_minimal() {
let entry: WithEntry = serde_yaml::from_str(
r#"
from: "$step1"
"#,
)
.unwrap();
assert_eq!(entry.source, BindingPath::parse("$step1").unwrap());
assert_eq!(entry.binding_type, BindingType::Any);
assert_eq!(entry.default, None);
assert!(!entry.lazy);
assert_eq!(entry.transform, None);
}
#[test]
fn with_deser_object_typed() {
let entry: WithEntry = serde_yaml::from_str(
r#"
from: "$step1.name"
type: string
"#,
)
.unwrap();
assert_eq!(entry.binding_type, BindingType::String);
}
#[test]
fn with_deser_object_with_transform() {
let entry: WithEntry = serde_yaml::from_str(
r#"
from: "$step1.text"
transform: "upper | trim"
"#,
)
.unwrap();
let t = entry.transform.unwrap();
assert_eq!(t.ops.len(), 2);
assert_eq!(t.ops[0], TransformOp::Upper);
assert_eq!(t.ops[1], TransformOp::Trim);
}
#[test]
fn with_deser_object_full() {
let entry: WithEntry = serde_yaml::from_str(
r#"
from: "$step1.abstract"
type: string
transform: "lower | trim"
default: "No abstract"
lazy: true
"#,
)
.unwrap();
assert_eq!(entry.source, BindingPath::parse("$step1.abstract").unwrap());
assert_eq!(entry.binding_type, BindingType::String);
assert!(entry.transform.is_some());
assert_eq!(entry.default, Some(json!("No abstract")));
assert!(entry.lazy);
}
#[test]
fn with_deser_object_lazy() {
let entry: WithEntry = serde_yaml::from_str(
r#"
from: "$step1.result"
lazy: true
"#,
)
.unwrap();
assert!(entry.lazy);
}
#[test]
fn with_deser_spec_empty() {
let spec: WithSpec = serde_yaml::from_str("{}").unwrap();
assert!(spec.is_empty());
}
#[test]
fn with_deser_spec_single() {
let spec: WithSpec = serde_yaml::from_str(r#"result: "$step1""#).unwrap();
assert_eq!(spec.len(), 1);
let entry = spec.get("result").unwrap();
assert_eq!(entry.source, BindingPath::parse("$step1").unwrap());
}
#[test]
fn with_deser_spec_mixed() {
let yaml = r#"
result: "$step1"
title: "$step1.title | upper"
summary:
from: "$step1.abstract"
type: string
transform: "lower | trim"
default: "N/A"
lazy: true
"#;
let spec: WithSpec = serde_yaml::from_str(yaml).unwrap();
assert_eq!(spec.len(), 3);
let result = spec.get("result").unwrap();
assert_eq!(result.source, BindingPath::parse("$step1").unwrap());
assert_eq!(result.transform, None);
let title = spec.get("title").unwrap();
assert_eq!(title.source, BindingPath::parse("$step1.title").unwrap());
let t = title.transform.as_ref().unwrap();
assert_eq!(t.ops[0], TransformOp::Upper);
let summary = spec.get("summary").unwrap();
assert_eq!(
summary.source,
BindingPath::parse("$step1.abstract").unwrap()
);
assert_eq!(summary.binding_type, BindingType::String);
assert!(summary.lazy);
assert_eq!(summary.default, Some(json!("N/A")));
}
#[test]
fn with_deser_object_missing_from_error() {
let result: Result<WithEntry, _> = serde_yaml::from_str(
r#"
type: string
"#,
);
assert!(result.is_err());
}
#[test]
fn with_deser_object_invalid_path_error() {
let result: Result<WithEntry, _> = serde_yaml::from_str(
r#"
from: "step1"
"#,
);
assert!(result.is_err());
}
#[test]
fn with_deser_object_invalid_transform_error() {
let result: Result<WithEntry, _> = serde_yaml::from_str(
r#"
from: "$step1"
transform: "nonexistent_op"
"#,
);
assert!(result.is_err());
}
#[test]
fn split_default_no_default() {
let (path, def) = split_default("$step1 | upper").unwrap();
assert_eq!(path, "$step1 | upper");
assert_eq!(def, None);
}
#[test]
fn split_default_simple() {
let (path, def) = split_default("$step1 ?? 42").unwrap();
assert_eq!(path, "$step1");
assert_eq!(def, Some("42"));
}
#[test]
fn split_default_inside_parens_ignored() {
let (path, def) = split_default(r#"$step1 | default("a ?? b")"#).unwrap();
assert_eq!(path, r#"$step1 | default("a ?? b")"#);
assert_eq!(def, None);
}
#[test]
fn split_default_after_parens() {
let (path, def) = split_default(r#"$step1 | default("inner") ?? "outer""#).unwrap();
assert_eq!(path, r#"$step1 | default("inner")"#);
assert_eq!(def, Some(r#""outer""#));
}
#[test]
fn split_transforms_no_pipe() {
let (path, t) = split_transforms("$step1");
assert_eq!(path, "$step1");
assert_eq!(t, None);
}
#[test]
fn split_transforms_single() {
let (path, t) = split_transforms("$step1 | upper");
assert_eq!(path, "$step1 ");
assert_eq!(t, Some(" upper"));
}
#[test]
fn split_transforms_chain() {
let (path, t) = split_transforms("$step1 | sort | unique");
assert_eq!(path, "$step1 ");
assert_eq!(t, Some(" sort | unique"));
}
#[test]
fn with_entry_parse_error_display() {
let err = WithEntryParseError {
input: "$bad".to_string(),
reason: "test error".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("NIKA-155"));
assert!(msg.contains("$bad"));
assert!(msg.contains("test error"));
}
#[test]
fn with_simple_path() {
let entry = parse_with_entry("$step1").unwrap();
assert_eq!(entry.task_id(), Some("step1"));
}
#[test]
fn with_deep_path() {
let entry = parse_with_entry("$step1.data.name").unwrap();
assert_eq!(entry.task_id(), Some("step1"));
assert_eq!(entry.source.segments.len(), 2);
}
#[test]
fn with_default_string() {
let entry = parse_with_entry(r#"$step1 ?? "N/A""#).unwrap();
assert_eq!(entry.default, Some(json!("N/A")));
}
}