Skip to main content

enact_core/background/
target_binding.rs

1//! Target Binding - Binds execution results to targets
2//!
3//! Target binding allows background executions to write their results
4//! to specific targets like thread titles, execution summaries, or memory.
5//!
6//! @see packages/enact-schemas/src/execution.schemas.ts
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11use crate::kernel::ids::ExecutionId;
12use crate::streaming::ThreadId;
13
14/// TargetBindingType - What kind of target to bind to
15/// @see packages/enact-schemas/src/execution.schemas.ts - targetBindingTypeSchema
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum TargetBindingType {
19    /// Set thread title
20    #[serde(rename = "thread.title")]
21    ThreadTitle,
22    /// Set thread summary
23    #[serde(rename = "thread.summary")]
24    ThreadSummary,
25    /// Set execution summary
26    #[serde(rename = "execution.summary")]
27    ExecutionSummary,
28    /// Add to message metadata
29    #[serde(rename = "message.metadata")]
30    MessageMetadata,
31    /// Create an artifact
32    #[serde(rename = "artifact.create")]
33    ArtifactCreate,
34    /// Write to memory
35    #[serde(rename = "memory.write")]
36    MemoryWrite,
37    /// Custom target (requires targetPath)
38    Custom,
39}
40
41/// TargetBindingTransform - Transform to apply before binding
42/// @see packages/enact-schemas/src/execution.schemas.ts - targetBindingSchema.transform
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
44#[serde(rename_all = "snake_case")]
45pub enum TargetBindingTransform {
46    /// No transform
47    #[default]
48    None,
49    /// Take first line only
50    FirstLine,
51    /// Truncate to max length
52    Truncate,
53    /// Extract JSON path
54    JsonExtract,
55}
56
57/// TargetBindingConfig - Configuration for binding execution result to a target
58/// @see packages/enact-schemas/src/execution.schemas.ts - targetBindingSchema
59#[derive(Debug, Clone, Serialize, Deserialize)]
60#[serde(rename_all = "camelCase")]
61pub struct TargetBindingConfig {
62    /// Target type
63    pub target_type: TargetBindingType,
64
65    /// Custom target path (for custom type)
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub target_path: Option<String>,
68
69    /// Transform to apply before binding
70    #[serde(default)]
71    pub transform: TargetBindingTransform,
72
73    /// Max length for truncate transform
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub max_length: Option<usize>,
76
77    /// JSON path for json_extract transform
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub json_path: Option<String>,
80
81    /// Thread ID to bind to (for thread.* targets)
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub thread_id: Option<ThreadId>,
84
85    /// Execution ID to bind to (for execution.* targets)
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub execution_id: Option<ExecutionId>,
88}
89
90impl TargetBindingConfig {
91    /// Create a thread title binding
92    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    /// Create a thread summary binding
105    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    /// Create an execution summary binding
118    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    /// Create a memory write binding
131    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    /// Create an artifact binding
144    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    /// Create a custom binding
157    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/// TargetBindingResult - Result of applying a target binding
171/// @see packages/enact-schemas/src/execution.schemas.ts - targetBindingResultSchema
172#[derive(Debug, Clone, Serialize, Deserialize)]
173#[serde(rename_all = "camelCase")]
174pub struct TargetBindingResult {
175    /// Target type
176    pub target_type: TargetBindingType,
177
178    /// Target path (for custom targets)
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub target_path: Option<String>,
181
182    /// Whether binding succeeded
183    pub success: bool,
184
185    /// Value that was applied
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub applied_value: Option<serde_json::Value>,
188
189    /// Error message if failed
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub error: Option<String>,
192
193    /// When binding was applied
194    pub applied_at: DateTime<Utc>,
195}
196
197impl TargetBindingResult {
198    /// Create a success result
199    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    /// Create a failure result
211    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
223/// Apply a transform to a value
224pub 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
252/// Truncate a string to a maximum length, adding ellipsis if needed
253fn 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
264/// Extract a value from JSON using a simple path
265/// Supports: $.field, $.field.subfield, $.array[0], $.array[*].field
266fn 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    // Simple path extraction (not full JSONPath)
271    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        // Check for array index
281        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                // Return all array elements
294                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    // Return the extracted value
316    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        // Test thread title
329        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        // Extract top-level field
355        let result = apply_transform(
356            json,
357            &TargetBindingTransform::JsonExtract,
358            None,
359            Some("$.name"),
360        );
361        assert_eq!(result.unwrap(), "test");
362
363        // Extract nested field
364        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        // Success
378        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        // Failure
383        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}