1use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11use crate::kernel::ids::ExecutionId;
12use crate::streaming::ThreadId;
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum TargetBindingType {
19 #[serde(rename = "thread.title")]
21 ThreadTitle,
22 #[serde(rename = "thread.summary")]
24 ThreadSummary,
25 #[serde(rename = "execution.summary")]
27 ExecutionSummary,
28 #[serde(rename = "message.metadata")]
30 MessageMetadata,
31 #[serde(rename = "artifact.create")]
33 ArtifactCreate,
34 #[serde(rename = "memory.write")]
36 MemoryWrite,
37 Custom,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
44#[serde(rename_all = "snake_case")]
45pub enum TargetBindingTransform {
46 #[default]
48 None,
49 FirstLine,
51 Truncate,
53 JsonExtract,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
60#[serde(rename_all = "camelCase")]
61pub struct TargetBindingConfig {
62 pub target_type: TargetBindingType,
64
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub target_path: Option<String>,
68
69 #[serde(default)]
71 pub transform: TargetBindingTransform,
72
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub max_length: Option<usize>,
76
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub json_path: Option<String>,
80
81 #[serde(skip_serializing_if = "Option::is_none")]
83 pub thread_id: Option<ThreadId>,
84
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub execution_id: Option<ExecutionId>,
88}
89
90impl TargetBindingConfig {
91 pub fn thread_title(thread_id: ThreadId) -> Self {
93 Self {
94 target_type: TargetBindingType::ThreadTitle,
95 target_path: None,
96 transform: TargetBindingTransform::FirstLine,
97 max_length: Some(100),
98 json_path: None,
99 thread_id: Some(thread_id),
100 execution_id: None,
101 }
102 }
103
104 pub fn thread_summary(thread_id: ThreadId) -> Self {
106 Self {
107 target_type: TargetBindingType::ThreadSummary,
108 target_path: None,
109 transform: TargetBindingTransform::Truncate,
110 max_length: Some(500),
111 json_path: None,
112 thread_id: Some(thread_id),
113 execution_id: None,
114 }
115 }
116
117 pub fn execution_summary(execution_id: ExecutionId) -> Self {
119 Self {
120 target_type: TargetBindingType::ExecutionSummary,
121 target_path: None,
122 transform: TargetBindingTransform::Truncate,
123 max_length: Some(500),
124 json_path: None,
125 thread_id: None,
126 execution_id: Some(execution_id),
127 }
128 }
129
130 pub fn memory_write(memory_key: String) -> Self {
132 Self {
133 target_type: TargetBindingType::MemoryWrite,
134 target_path: Some(memory_key),
135 transform: TargetBindingTransform::None,
136 max_length: None,
137 json_path: None,
138 thread_id: None,
139 execution_id: None,
140 }
141 }
142
143 pub fn artifact(artifact_type: String) -> Self {
145 Self {
146 target_type: TargetBindingType::ArtifactCreate,
147 target_path: Some(artifact_type),
148 transform: TargetBindingTransform::None,
149 max_length: None,
150 json_path: None,
151 thread_id: None,
152 execution_id: None,
153 }
154 }
155
156 pub fn custom(path: String) -> Self {
158 Self {
159 target_type: TargetBindingType::Custom,
160 target_path: Some(path),
161 transform: TargetBindingTransform::None,
162 max_length: None,
163 json_path: None,
164 thread_id: None,
165 execution_id: None,
166 }
167 }
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
173#[serde(rename_all = "camelCase")]
174pub struct TargetBindingResult {
175 pub target_type: TargetBindingType,
177
178 #[serde(skip_serializing_if = "Option::is_none")]
180 pub target_path: Option<String>,
181
182 pub success: bool,
184
185 #[serde(skip_serializing_if = "Option::is_none")]
187 pub applied_value: Option<serde_json::Value>,
188
189 #[serde(skip_serializing_if = "Option::is_none")]
191 pub error: Option<String>,
192
193 pub applied_at: DateTime<Utc>,
195}
196
197impl TargetBindingResult {
198 pub fn success(config: &TargetBindingConfig, applied_value: serde_json::Value) -> Self {
200 Self {
201 target_type: config.target_type.clone(),
202 target_path: config.target_path.clone(),
203 success: true,
204 applied_value: Some(applied_value),
205 error: None,
206 applied_at: Utc::now(),
207 }
208 }
209
210 pub fn failure(config: &TargetBindingConfig, error: impl Into<String>) -> Self {
212 Self {
213 target_type: config.target_type.clone(),
214 target_path: config.target_path.clone(),
215 success: false,
216 applied_value: None,
217 error: Some(error.into()),
218 applied_at: Utc::now(),
219 }
220 }
221}
222
223pub fn apply_transform(
225 value: &str,
226 transform: &TargetBindingTransform,
227 max_length: Option<usize>,
228 json_path: Option<&str>,
229) -> Result<String, String> {
230 match transform {
231 TargetBindingTransform::None => Ok(value.to_string()),
232 TargetBindingTransform::FirstLine => {
233 let first_line = value.lines().next().unwrap_or("").trim();
234 let result = if let Some(max) = max_length {
235 truncate_string(first_line, max)
236 } else {
237 first_line.to_string()
238 };
239 Ok(result)
240 }
241 TargetBindingTransform::Truncate => {
242 let max = max_length.unwrap_or(500);
243 Ok(truncate_string(value, max))
244 }
245 TargetBindingTransform::JsonExtract => {
246 let path = json_path.ok_or("json_path required for JsonExtract transform")?;
247 extract_json_path(value, path)
248 }
249 }
250}
251
252fn truncate_string(s: &str, max_len: usize) -> String {
254 if s.len() <= max_len {
255 s.to_string()
256 } else if max_len <= 3 {
257 s.chars().take(max_len).collect()
258 } else {
259 let truncated: String = s.chars().take(max_len - 3).collect();
260 format!("{}...", truncated)
261 }
262}
263
264fn extract_json_path(json_str: &str, path: &str) -> Result<String, String> {
267 let value: serde_json::Value =
268 serde_json::from_str(json_str).map_err(|e| format!("Invalid JSON: {}", e))?;
269
270 let path = path.trim_start_matches('$').trim_start_matches('.');
272 let parts: Vec<&str> = path.split('.').collect();
273
274 let mut current = &value;
275 for part in parts {
276 if part.is_empty() {
277 continue;
278 }
279
280 if let Some(idx_start) = part.find('[') {
282 let field = &part[..idx_start];
283 if !field.is_empty() {
284 current = current
285 .get(field)
286 .ok_or_else(|| format!("Field '{}' not found", field))?;
287 }
288
289 let idx_end = part.find(']').ok_or("Malformed array index")?;
290 let idx_str = &part[idx_start + 1..idx_end];
291
292 if idx_str == "*" {
293 if let Some(arr) = current.as_array() {
295 let results: Vec<String> = arr.iter().map(|v| v.to_string()).collect();
296 return Ok(results.join(", "));
297 } else {
298 return Err("Expected array for [*] index".to_string());
299 }
300 } else {
301 let idx: usize = idx_str
302 .parse()
303 .map_err(|_| format!("Invalid array index: {}", idx_str))?;
304 current = current
305 .get(idx)
306 .ok_or_else(|| format!("Array index {} out of bounds", idx))?;
307 }
308 } else {
309 current = current
310 .get(part)
311 .ok_or_else(|| format!("Field '{}' not found", part))?;
312 }
313 }
314
315 match current {
317 serde_json::Value::String(s) => Ok(s.clone()),
318 v => Ok(v.to_string()),
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 #[test]
327 fn test_target_binding_types() {
328 let thread_id = ThreadId::new();
330 let config = TargetBindingConfig::thread_title(thread_id.clone());
331 assert_eq!(config.target_type, TargetBindingType::ThreadTitle);
332 assert_eq!(config.thread_id, Some(thread_id));
333 assert_eq!(config.transform, TargetBindingTransform::FirstLine);
334 }
335
336 #[test]
337 fn test_transform_first_line() {
338 let value = "First line\nSecond line\nThird line";
339 let result = apply_transform(value, &TargetBindingTransform::FirstLine, None, None);
340 assert_eq!(result.unwrap(), "First line");
341 }
342
343 #[test]
344 fn test_transform_truncate() {
345 let value = "This is a long string that needs to be truncated";
346 let result = apply_transform(value, &TargetBindingTransform::Truncate, Some(20), None);
347 assert_eq!(result.unwrap(), "This is a long st...");
348 }
349
350 #[test]
351 fn test_transform_json_extract() {
352 let json = r#"{"name": "test", "nested": {"value": 42}}"#;
353
354 let result = apply_transform(
356 json,
357 &TargetBindingTransform::JsonExtract,
358 None,
359 Some("$.name"),
360 );
361 assert_eq!(result.unwrap(), "test");
362
363 let result = apply_transform(
365 json,
366 &TargetBindingTransform::JsonExtract,
367 None,
368 Some("$.nested.value"),
369 );
370 assert_eq!(result.unwrap(), "42");
371 }
372
373 #[test]
374 fn test_target_binding_result() {
375 let config = TargetBindingConfig::thread_title(ThreadId::new());
376
377 let result = TargetBindingResult::success(&config, serde_json::json!("New Title"));
379 assert!(result.success);
380 assert_eq!(result.applied_value, Some(serde_json::json!("New Title")));
381
382 let result = TargetBindingResult::failure(&config, "Thread not found");
384 assert!(!result.success);
385 assert_eq!(result.error, Some("Thread not found".to_string()));
386 }
387
388 #[test]
389 fn test_truncate_string() {
390 assert_eq!(truncate_string("short", 10), "short");
391 assert_eq!(truncate_string("exactly10c", 10), "exactly10c");
392 assert_eq!(truncate_string("this is too long", 10), "this is...");
393 assert_eq!(truncate_string("ab", 2), "ab");
394 assert_eq!(truncate_string("abc", 3), "abc");
395 }
396}