use std::fmt;
use std::sync::Arc;
use serde::Deserialize;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct BindingPath {
pub source: BindingSource,
pub segments: Vec<PathSegment>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum BindingSource {
Task(Arc<str>),
Context(Arc<str>),
Input(Arc<str>),
Env(Arc<str>),
LoopVar(Arc<str>),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum PathSegment {
Field(Arc<str>),
Index(usize),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BindingType {
#[default]
Any,
String,
Number,
Integer,
Boolean,
Array,
Object,
}
#[derive(Debug, Clone, PartialEq)]
pub struct BindingPathError {
pub input: String,
pub reason: String,
}
impl fmt::Display for BindingPathError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"[NIKA-150] Invalid binding path '{}': {}",
self.input, self.reason
)
}
}
impl std::error::Error for BindingPathError {}
const RESERVED_CONTEXT: &str = "context";
const RESERVED_INPUTS: &str = "inputs";
const RESERVED_ENV: &str = "env";
impl BindingPath {
pub fn parse(input: &str) -> Result<Self, BindingPathError> {
Self::parse_inner(input, None)
}
pub fn parse_with_loop_vars(input: &str, loop_vars: &[&str]) -> Result<Self, BindingPathError> {
Self::parse_inner(input, Some(loop_vars))
}
fn parse_inner(input: &str, loop_vars: Option<&[&str]>) -> Result<Self, BindingPathError> {
let trimmed = input.trim();
let rest = trimmed.strip_prefix('$').ok_or_else(|| BindingPathError {
input: trimmed.to_string(),
reason: "binding paths must start with '$'".to_string(),
})?;
if rest.is_empty() {
return Err(BindingPathError {
input: trimmed.to_string(),
reason: "empty path after '$'".to_string(),
});
}
let tokens = tokenize_path(rest).map_err(|reason| BindingPathError {
input: trimmed.to_string(),
reason,
})?;
if tokens.is_empty() {
return Err(BindingPathError {
input: trimmed.to_string(),
reason: "empty path after '$'".to_string(),
});
}
let root = match &tokens[0] {
PathToken::Field(name) => name.as_str(),
PathToken::Index(_) => {
return Err(BindingPathError {
input: trimmed.to_string(),
reason: "path cannot start with an array index".to_string(),
});
}
};
match root {
RESERVED_CONTEXT => {
if tokens.len() < 2 {
return Err(BindingPathError {
input: trimmed.to_string(),
reason: "'$context' requires a sub-path (e.g., '$context.files.brand')"
.to_string(),
});
}
let sub_path = tokens_to_dotted_string(&tokens[1..]);
Ok(BindingPath {
source: BindingSource::Context(Arc::from(sub_path.as_str())),
segments: vec![],
})
}
RESERVED_INPUTS => {
if tokens.len() < 2 {
return Err(BindingPathError {
input: trimmed.to_string(),
reason: "'$inputs' requires a sub-path (e.g., '$inputs.locale')"
.to_string(),
});
}
let sub_path = tokens_to_dotted_string(&tokens[1..]);
Ok(BindingPath {
source: BindingSource::Input(Arc::from(sub_path.as_str())),
segments: vec![],
})
}
RESERVED_ENV => {
if tokens.len() < 2 {
return Err(BindingPathError {
input: trimmed.to_string(),
reason: "'$env' requires a variable name (e.g., '$env.API_URL')"
.to_string(),
});
}
let var_name = tokens_to_dotted_string(&tokens[1..]);
Ok(BindingPath {
source: BindingSource::Env(Arc::from(var_name.as_str())),
segments: vec![],
})
}
_ => {
let is_loop_var = loop_vars.map(|vars| vars.contains(&root)).unwrap_or(false);
let source = if is_loop_var {
BindingSource::LoopVar(Arc::from(root))
} else {
BindingSource::Task(Arc::from(root))
};
let segments = tokens[1..]
.iter()
.map(|t| match t {
PathToken::Field(name) => PathSegment::Field(Arc::from(name.as_str())),
PathToken::Index(idx) => PathSegment::Index(*idx),
})
.collect();
Ok(BindingPath { source, segments })
}
}
}
pub fn task_id(&self) -> Option<&Arc<str>> {
match &self.source {
BindingSource::Task(id) => Some(id),
_ => None,
}
}
pub fn is_task_ref(&self) -> bool {
matches!(self.source, BindingSource::Task(_))
}
}
#[derive(Debug, Clone)]
enum PathToken {
Field(String),
Index(usize),
}
fn tokenize_path(input: &str) -> Result<Vec<PathToken>, String> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut chars = input.chars().peekable();
while let Some(&ch) = chars.peek() {
match ch {
'.' => {
chars.next();
if !current.is_empty() {
tokens.push(PathToken::Field(std::mem::take(&mut current)));
}
if tokens.is_empty() && current.is_empty() {
return Err("path cannot start with '.'".to_string());
}
}
'[' => {
chars.next();
if !current.is_empty() {
tokens.push(PathToken::Field(std::mem::take(&mut current)));
}
let mut idx_str = String::new();
loop {
match chars.next() {
Some(']') => break,
Some(c) => idx_str.push(c),
None => return Err("unclosed bracket in path".to_string()),
}
}
let idx: usize = idx_str
.parse()
.map_err(|_| format!("invalid array index: '{}'", idx_str))?;
tokens.push(PathToken::Index(idx));
}
']' => {
return Err("unexpected ']' without matching '['".to_string());
}
_ => {
chars.next();
current.push(ch);
}
}
}
if !current.is_empty() {
tokens.push(PathToken::Field(current));
}
Ok(tokens)
}
fn tokens_to_dotted_string(tokens: &[PathToken]) -> String {
tokens
.iter()
.map(|t| match t {
PathToken::Field(name) => name.clone(),
PathToken::Index(idx) => idx.to_string(),
})
.collect::<Vec<_>>()
.join(".")
}
impl fmt::Display for BindingPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "${}", self.source)?;
for seg in &self.segments {
write!(f, "{}", seg)?;
}
Ok(())
}
}
impl fmt::Display for BindingSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
BindingSource::Task(id) => write!(f, "{}", id),
BindingSource::Context(path) => write!(f, "context.{}", path),
BindingSource::Input(path) => write!(f, "inputs.{}", path),
BindingSource::Env(var) => write!(f, "env.{}", var),
BindingSource::LoopVar(name) => write!(f, "{}", name),
}
}
}
impl fmt::Display for PathSegment {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PathSegment::Field(name) => write!(f, ".{}", name),
PathSegment::Index(idx) => write!(f, "[{}]", idx),
}
}
}
impl fmt::Display for BindingType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
BindingType::Any => write!(f, "any"),
BindingType::String => write!(f, "string"),
BindingType::Number => write!(f, "number"),
BindingType::Integer => write!(f, "integer"),
BindingType::Boolean => write!(f, "boolean"),
BindingType::Array => write!(f, "array"),
BindingType::Object => write!(f, "object"),
}
}
}
impl BindingSource {
pub fn is_task(&self) -> bool {
matches!(self, BindingSource::Task(_))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_simple_task_ref() {
let bp = BindingPath::parse("$step1").unwrap();
assert_eq!(bp.source, BindingSource::Task(Arc::from("step1")));
assert!(bp.segments.is_empty());
}
#[test]
fn parse_task_with_field() {
let bp = BindingPath::parse("$step1.output").unwrap();
assert_eq!(bp.source, BindingSource::Task(Arc::from("step1")));
assert_eq!(bp.segments, vec![PathSegment::Field(Arc::from("output"))]);
}
#[test]
fn parse_task_deep_path() {
let bp = BindingPath::parse("$step1.data.items[0].name").unwrap();
assert_eq!(bp.source, BindingSource::Task(Arc::from("step1")));
assert_eq!(
bp.segments,
vec![
PathSegment::Field(Arc::from("data")),
PathSegment::Field(Arc::from("items")),
PathSegment::Index(0),
PathSegment::Field(Arc::from("name")),
]
);
}
#[test]
fn parse_context_file() {
let bp = BindingPath::parse("$context.files.brand").unwrap();
assert_eq!(bp.source, BindingSource::Context(Arc::from("files.brand")));
assert!(bp.segments.is_empty());
}
#[test]
fn parse_context_session() {
let bp = BindingPath::parse("$context.session").unwrap();
assert_eq!(bp.source, BindingSource::Context(Arc::from("session")));
assert!(bp.segments.is_empty());
}
#[test]
fn parse_input() {
let bp = BindingPath::parse("$inputs.locale").unwrap();
assert_eq!(bp.source, BindingSource::Input(Arc::from("locale")));
assert!(bp.segments.is_empty());
}
#[test]
fn parse_input_nested() {
let bp = BindingPath::parse("$inputs.config.theme").unwrap();
assert_eq!(bp.source, BindingSource::Input(Arc::from("config.theme")));
assert!(bp.segments.is_empty());
}
#[test]
fn parse_env() {
let bp = BindingPath::parse("$env.API_URL").unwrap();
assert_eq!(bp.source, BindingSource::Env(Arc::from("API_URL")));
assert!(bp.segments.is_empty());
}
#[test]
fn parse_loop_var() {
let bp = BindingPath::parse_with_loop_vars("$item", &["item"]).unwrap();
assert_eq!(bp.source, BindingSource::LoopVar(Arc::from("item")));
assert!(bp.segments.is_empty());
}
#[test]
fn parse_loop_var_with_field() {
let bp = BindingPath::parse_with_loop_vars("$item.name", &["item"]).unwrap();
assert_eq!(bp.source, BindingSource::LoopVar(Arc::from("item")));
assert_eq!(bp.segments, vec![PathSegment::Field(Arc::from("name"))]);
}
#[test]
fn parse_without_loop_hint_is_task() {
let bp = BindingPath::parse("$item").unwrap();
assert_eq!(bp.source, BindingSource::Task(Arc::from("item")));
}
#[test]
fn parse_index_segment() {
let bp = BindingPath::parse("$data[0]").unwrap();
assert_eq!(bp.source, BindingSource::Task(Arc::from("data")));
assert_eq!(bp.segments, vec![PathSegment::Index(0)]);
}
#[test]
fn parse_multiple_indexes() {
let bp = BindingPath::parse("$data[0].items[1]").unwrap();
assert_eq!(bp.source, BindingSource::Task(Arc::from("data")));
assert_eq!(
bp.segments,
vec![
PathSegment::Index(0),
PathSegment::Field(Arc::from("items")),
PathSegment::Index(1),
]
);
}
#[test]
fn parse_consecutive_indexes() {
let bp = BindingPath::parse("$matrix[0][1]").unwrap();
assert_eq!(bp.source, BindingSource::Task(Arc::from("matrix")));
assert_eq!(
bp.segments,
vec![PathSegment::Index(0), PathSegment::Index(1)]
);
}
#[test]
fn parse_missing_dollar() {
let err = BindingPath::parse("step1").unwrap_err();
assert!(err.reason.contains("must start with '$'"));
}
#[test]
fn parse_empty() {
let err = BindingPath::parse("$").unwrap_err();
assert!(err.reason.contains("empty path"));
}
#[test]
fn parse_empty_string() {
let err = BindingPath::parse("").unwrap_err();
assert!(err.reason.contains("must start with '$'"));
}
#[test]
fn parse_unclosed_bracket() {
let err = BindingPath::parse("$data[0").unwrap_err();
assert!(err.reason.contains("unclosed bracket"));
}
#[test]
fn parse_invalid_index() {
let err = BindingPath::parse("$data[abc]").unwrap_err();
assert!(err.reason.contains("invalid array index"));
}
#[test]
fn parse_context_without_subpath() {
let err = BindingPath::parse("$context").unwrap_err();
assert!(err.reason.contains("requires a sub-path"));
}
#[test]
fn parse_inputs_without_subpath() {
let err = BindingPath::parse("$inputs").unwrap_err();
assert!(err.reason.contains("requires a sub-path"));
}
#[test]
fn parse_env_without_var() {
let err = BindingPath::parse("$env").unwrap_err();
assert!(err.reason.contains("requires a variable name"));
}
#[test]
fn parse_unexpected_close_bracket() {
let err = BindingPath::parse("$data]0[").unwrap_err();
assert!(err.reason.contains("unexpected ']'"));
}
#[test]
fn parse_with_leading_whitespace() {
let bp = BindingPath::parse(" $step1.name ").unwrap();
assert_eq!(bp.source, BindingSource::Task(Arc::from("step1")));
assert_eq!(bp.segments, vec![PathSegment::Field(Arc::from("name"))]);
}
#[test]
fn display_roundtrip_task() {
let original = "$step1.data.items[0].name";
let bp = BindingPath::parse(original).unwrap();
let displayed = bp.to_string();
let reparsed = BindingPath::parse(&displayed).unwrap();
assert_eq!(bp, reparsed);
}
#[test]
fn display_roundtrip_context() {
let original = "$context.files.brand";
let bp = BindingPath::parse(original).unwrap();
let displayed = bp.to_string();
let reparsed = BindingPath::parse(&displayed).unwrap();
assert_eq!(bp, reparsed);
}
#[test]
fn display_roundtrip_inputs() {
let original = "$inputs.config.theme";
let bp = BindingPath::parse(original).unwrap();
let displayed = bp.to_string();
let reparsed = BindingPath::parse(&displayed).unwrap();
assert_eq!(bp, reparsed);
}
#[test]
fn display_roundtrip_env() {
let original = "$env.API_URL";
let bp = BindingPath::parse(original).unwrap();
let displayed = bp.to_string();
let reparsed = BindingPath::parse(&displayed).unwrap();
assert_eq!(bp, reparsed);
}
#[test]
fn display_simple_task() {
let bp = BindingPath::parse("$step1").unwrap();
assert_eq!(bp.to_string(), "$step1");
}
#[test]
fn task_id_extraction() {
let bp = BindingPath::parse("$step1.field").unwrap();
assert_eq!(bp.task_id().map(|s| s.as_ref()), Some("step1"));
}
#[test]
fn task_id_none_for_context() {
let bp = BindingPath::parse("$context.files.brand").unwrap();
assert!(bp.task_id().is_none());
}
#[test]
fn is_task_ref_true() {
let bp = BindingPath::parse("$step1").unwrap();
assert!(bp.is_task_ref());
}
#[test]
fn is_task_ref_false_for_context() {
let bp = BindingPath::parse("$context.files.brand").unwrap();
assert!(!bp.is_task_ref());
}
#[test]
fn is_task_ref_false_for_input() {
let bp = BindingPath::parse("$inputs.locale").unwrap();
assert!(!bp.is_task_ref());
}
#[test]
fn is_task_ref_false_for_env() {
let bp = BindingPath::parse("$env.HOME").unwrap();
assert!(!bp.is_task_ref());
}
#[test]
fn source_is_task() {
assert!(BindingSource::Task(Arc::from("x")).is_task());
assert!(!BindingSource::Context(Arc::from("x")).is_task());
assert!(!BindingSource::Input(Arc::from("x")).is_task());
assert!(!BindingSource::Env(Arc::from("x")).is_task());
assert!(!BindingSource::LoopVar(Arc::from("x")).is_task());
}
#[test]
fn binding_type_default() {
assert_eq!(BindingType::default(), BindingType::Any);
}
#[test]
fn binding_type_deserialize() {
let t: BindingType = serde_json::from_str(r#""string""#).unwrap();
assert_eq!(t, BindingType::String);
}
#[test]
fn binding_type_all_variants() {
let cases = [
(r#""any""#, BindingType::Any),
(r#""string""#, BindingType::String),
(r#""number""#, BindingType::Number),
(r#""integer""#, BindingType::Integer),
(r#""boolean""#, BindingType::Boolean),
(r#""array""#, BindingType::Array),
(r#""object""#, BindingType::Object),
];
for (json, expected) in cases {
let t: BindingType = serde_json::from_str(json).unwrap();
assert_eq!(t, expected, "Failed for JSON: {}", json);
}
}
#[test]
fn binding_type_display() {
assert_eq!(BindingType::Any.to_string(), "any");
assert_eq!(BindingType::String.to_string(), "string");
assert_eq!(BindingType::Number.to_string(), "number");
assert_eq!(BindingType::Integer.to_string(), "integer");
assert_eq!(BindingType::Boolean.to_string(), "boolean");
assert_eq!(BindingType::Array.to_string(), "array");
assert_eq!(BindingType::Object.to_string(), "object");
}
#[test]
fn binding_type_invalid_deserialize() {
let result = serde_json::from_str::<BindingType>(r#""unknown""#);
assert!(result.is_err());
}
#[test]
fn parse_underscore_task_id() {
let bp = BindingPath::parse("$my_step_1.result").unwrap();
assert_eq!(bp.source, BindingSource::Task(Arc::from("my_step_1")));
}
#[test]
fn parse_context_deep_path() {
let bp = BindingPath::parse("$context.files.config.nested.deep").unwrap();
assert_eq!(
bp.source,
BindingSource::Context(Arc::from("files.config.nested.deep"))
);
}
#[test]
fn parse_env_with_numbers() {
let bp = BindingPath::parse("$env.AWS_REGION_1").unwrap();
assert_eq!(bp.source, BindingSource::Env(Arc::from("AWS_REGION_1")));
}
#[test]
fn binding_path_error_display() {
let err = BindingPathError {
input: "step1".to_string(),
reason: "must start with '$'".to_string(),
};
assert!(err.to_string().contains("NIKA-150"));
assert!(err.to_string().contains("step1"));
}
#[test]
fn clone_and_eq() {
let bp1 = BindingPath::parse("$step1.name").unwrap();
let bp2 = bp1.clone();
assert_eq!(bp1, bp2);
}
#[test]
fn hash_consistency() {
use std::collections::HashSet;
let bp1 = BindingPath::parse("$step1.name").unwrap();
let bp2 = BindingPath::parse("$step1.name").unwrap();
let mut set = HashSet::new();
set.insert(bp1);
set.insert(bp2);
assert_eq!(set.len(), 1); }
}