use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::kernel::ids::ExecutionId;
use crate::streaming::ThreadId;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TargetBindingType {
#[serde(rename = "thread.title")]
ThreadTitle,
#[serde(rename = "thread.summary")]
ThreadSummary,
#[serde(rename = "execution.summary")]
ExecutionSummary,
#[serde(rename = "message.metadata")]
MessageMetadata,
#[serde(rename = "artifact.create")]
ArtifactCreate,
#[serde(rename = "memory.write")]
MemoryWrite,
Custom,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum TargetBindingTransform {
#[default]
None,
FirstLine,
Truncate,
JsonExtract,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TargetBindingConfig {
pub target_type: TargetBindingType,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_path: Option<String>,
#[serde(default)]
pub transform: TargetBindingTransform,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_length: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub json_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thread_id: Option<ThreadId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub execution_id: Option<ExecutionId>,
}
impl TargetBindingConfig {
pub fn thread_title(thread_id: ThreadId) -> Self {
Self {
target_type: TargetBindingType::ThreadTitle,
target_path: None,
transform: TargetBindingTransform::FirstLine,
max_length: Some(100),
json_path: None,
thread_id: Some(thread_id),
execution_id: None,
}
}
pub fn thread_summary(thread_id: ThreadId) -> Self {
Self {
target_type: TargetBindingType::ThreadSummary,
target_path: None,
transform: TargetBindingTransform::Truncate,
max_length: Some(500),
json_path: None,
thread_id: Some(thread_id),
execution_id: None,
}
}
pub fn execution_summary(execution_id: ExecutionId) -> Self {
Self {
target_type: TargetBindingType::ExecutionSummary,
target_path: None,
transform: TargetBindingTransform::Truncate,
max_length: Some(500),
json_path: None,
thread_id: None,
execution_id: Some(execution_id),
}
}
pub fn memory_write(memory_key: String) -> Self {
Self {
target_type: TargetBindingType::MemoryWrite,
target_path: Some(memory_key),
transform: TargetBindingTransform::None,
max_length: None,
json_path: None,
thread_id: None,
execution_id: None,
}
}
pub fn artifact(artifact_type: String) -> Self {
Self {
target_type: TargetBindingType::ArtifactCreate,
target_path: Some(artifact_type),
transform: TargetBindingTransform::None,
max_length: None,
json_path: None,
thread_id: None,
execution_id: None,
}
}
pub fn custom(path: String) -> Self {
Self {
target_type: TargetBindingType::Custom,
target_path: Some(path),
transform: TargetBindingTransform::None,
max_length: None,
json_path: None,
thread_id: None,
execution_id: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TargetBindingResult {
pub target_type: TargetBindingType,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_path: Option<String>,
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub applied_value: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
pub applied_at: DateTime<Utc>,
}
impl TargetBindingResult {
pub fn success(config: &TargetBindingConfig, applied_value: serde_json::Value) -> Self {
Self {
target_type: config.target_type.clone(),
target_path: config.target_path.clone(),
success: true,
applied_value: Some(applied_value),
error: None,
applied_at: Utc::now(),
}
}
pub fn failure(config: &TargetBindingConfig, error: impl Into<String>) -> Self {
Self {
target_type: config.target_type.clone(),
target_path: config.target_path.clone(),
success: false,
applied_value: None,
error: Some(error.into()),
applied_at: Utc::now(),
}
}
}
pub fn apply_transform(
value: &str,
transform: &TargetBindingTransform,
max_length: Option<usize>,
json_path: Option<&str>,
) -> Result<String, String> {
match transform {
TargetBindingTransform::None => Ok(value.to_string()),
TargetBindingTransform::FirstLine => {
let first_line = value.lines().next().unwrap_or("").trim();
let result = if let Some(max) = max_length {
truncate_string(first_line, max)
} else {
first_line.to_string()
};
Ok(result)
}
TargetBindingTransform::Truncate => {
let max = max_length.unwrap_or(500);
Ok(truncate_string(value, max))
}
TargetBindingTransform::JsonExtract => {
let path = json_path.ok_or("json_path required for JsonExtract transform")?;
extract_json_path(value, path)
}
}
}
fn truncate_string(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else if max_len <= 3 {
s.chars().take(max_len).collect()
} else {
let truncated: String = s.chars().take(max_len - 3).collect();
format!("{}...", truncated)
}
}
fn extract_json_path(json_str: &str, path: &str) -> Result<String, String> {
let value: serde_json::Value =
serde_json::from_str(json_str).map_err(|e| format!("Invalid JSON: {}", e))?;
let path = path.trim_start_matches('$').trim_start_matches('.');
let parts: Vec<&str> = path.split('.').collect();
let mut current = &value;
for part in parts {
if part.is_empty() {
continue;
}
if let Some(idx_start) = part.find('[') {
let field = &part[..idx_start];
if !field.is_empty() {
current = current
.get(field)
.ok_or_else(|| format!("Field '{}' not found", field))?;
}
let idx_end = part.find(']').ok_or("Malformed array index")?;
let idx_str = &part[idx_start + 1..idx_end];
if idx_str == "*" {
if let Some(arr) = current.as_array() {
let results: Vec<String> = arr.iter().map(|v| v.to_string()).collect();
return Ok(results.join(", "));
} else {
return Err("Expected array for [*] index".to_string());
}
} else {
let idx: usize = idx_str
.parse()
.map_err(|_| format!("Invalid array index: {}", idx_str))?;
current = current
.get(idx)
.ok_or_else(|| format!("Array index {} out of bounds", idx))?;
}
} else {
current = current
.get(part)
.ok_or_else(|| format!("Field '{}' not found", part))?;
}
}
match current {
serde_json::Value::String(s) => Ok(s.clone()),
v => Ok(v.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_target_binding_types() {
let thread_id = ThreadId::new();
let config = TargetBindingConfig::thread_title(thread_id.clone());
assert_eq!(config.target_type, TargetBindingType::ThreadTitle);
assert_eq!(config.thread_id, Some(thread_id));
assert_eq!(config.transform, TargetBindingTransform::FirstLine);
}
#[test]
fn test_transform_first_line() {
let value = "First line\nSecond line\nThird line";
let result = apply_transform(value, &TargetBindingTransform::FirstLine, None, None);
assert_eq!(result.unwrap(), "First line");
}
#[test]
fn test_transform_truncate() {
let value = "This is a long string that needs to be truncated";
let result = apply_transform(value, &TargetBindingTransform::Truncate, Some(20), None);
assert_eq!(result.unwrap(), "This is a long st...");
}
#[test]
fn test_transform_json_extract() {
let json = r#"{"name": "test", "nested": {"value": 42}}"#;
let result = apply_transform(
json,
&TargetBindingTransform::JsonExtract,
None,
Some("$.name"),
);
assert_eq!(result.unwrap(), "test");
let result = apply_transform(
json,
&TargetBindingTransform::JsonExtract,
None,
Some("$.nested.value"),
);
assert_eq!(result.unwrap(), "42");
}
#[test]
fn test_target_binding_result() {
let config = TargetBindingConfig::thread_title(ThreadId::new());
let result = TargetBindingResult::success(&config, serde_json::json!("New Title"));
assert!(result.success);
assert_eq!(result.applied_value, Some(serde_json::json!("New Title")));
let result = TargetBindingResult::failure(&config, "Thread not found");
assert!(!result.success);
assert_eq!(result.error, Some("Thread not found".to_string()));
}
#[test]
fn test_truncate_string() {
assert_eq!(truncate_string("short", 10), "short");
assert_eq!(truncate_string("exactly10c", 10), "exactly10c");
assert_eq!(truncate_string("this is too long", 10), "this is...");
assert_eq!(truncate_string("ab", 2), "ab");
assert_eq!(truncate_string("abc", 3), "abc");
}
}