use serde_json::{Map, Value, json}; use crate::{RuntimeError, WindowExecutionResult}; pub fn collect_window_result( response: &Value, config: &crank_core::StreamingConfig, ) -> Result { let mut items = extract_path_value(response, config.items_path.as_deref()) .and_then(|value| value.as_array().cloned()) .unwrap_or_default(); let cursor = extract_path_value(response, config.cursor_path.as_deref()).cloned(); let done = extract_path_value(response, config.done_path.as_deref()) .and_then(Value::as_bool) .unwrap_or(true); let mut truncated = false; let mut has_more = !done; if let Some(max_items) = config.max_items.map(|value| value as usize) { if items.len() > max_items { items.truncate(max_items); truncated = true; has_more = true; } } let mut summary = build_summary(response, &items, config); redact_and_truncate(&mut summary, &mut items, config); if let Some(max_bytes) = config.max_bytes.map(|value| value as usize) { while payload_size_bytes(&summary, &items, cursor.as_ref()) > max_bytes && !items.is_empty() { items.pop(); truncated = true; has_more = true; } if payload_size_bytes(&summary, &items, cursor.as_ref()) > max_bytes { summary = json!({ "notice": "window payload exceeded byte limit", "items_returned": items.len() }); truncated = true; has_more = true; } } if matches!( config.aggregation_mode, crank_core::AggregationMode::SummaryOnly ) { items.clear(); } Ok(WindowExecutionResult { summary, items, cursor, window_complete: !has_more, truncated, has_more, }) } fn build_summary(response: &Value, items: &[Value], config: &crank_core::StreamingConfig) -> Value { match config.aggregation_mode { crank_core::AggregationMode::RawItems => Value::Null, crank_core::AggregationMode::SummaryOnly | crank_core::AggregationMode::SummaryPlusSamples => { extract_path_value(response, config.summary_path.as_deref()) .cloned() .unwrap_or_else(|| stats_summary(items)) } crank_core::AggregationMode::Stats => stats_summary(items), crank_core::AggregationMode::LatestState => items.last().cloned().unwrap_or(Value::Null), } } fn stats_summary(items: &[Value]) -> Value { let mut summary = Map::new(); summary.insert("items_total".to_owned(), Value::from(items.len() as u64)); summary.insert("items_sampled".to_owned(), Value::from(items.len() as u64)); Value::Object(summary) } fn redact_and_truncate( summary: &mut Value, items: &mut [Value], config: &crank_core::StreamingConfig, ) { crate::redaction::redact_paths(summary, &config.redacted_paths); for item in items.iter_mut() { crate::redaction::redact_paths(item, &config.redacted_paths); } if config.truncate_item_fields { if let Some(max_len) = config.max_field_length.map(|value| value as usize) { for item in items.iter_mut() { crate::redaction::truncate_item_fields(item, max_len); } } } } fn payload_size_bytes(summary: &Value, items: &[Value], cursor: Option<&Value>) -> usize { serde_json::to_vec(&json!({ "summary": summary, "items": items, "cursor": cursor })) .map(|value| value.len()) .unwrap_or(usize::MAX) } pub fn extract_path_value<'a>(value: &'a Value, path: Option<&str>) -> Option<&'a Value> { let path = path?; let path = path.strip_prefix("$.").or_else(|| path.strip_prefix('$'))?; if path.is_empty() { return Some(value); } let mut current = value; for segment in path.split('.') { current = current.get(segment)?; } Some(current) } #[cfg(test)] mod tests { use serde_json::{Value, json}; use crank_core::{ AggregationMode, ExecutionMode, StreamingConfig, ToolFamilyConfig, TransportBehavior, }; use super::collect_window_result; fn window_config() -> StreamingConfig { StreamingConfig { mode: ExecutionMode::Window, transport_behavior: TransportBehavior::ServerStream, window_duration_ms: Some(3_000), poll_interval_ms: None, upstream_timeout_ms: Some(5_000), idle_timeout_ms: None, max_session_lifetime_ms: None, max_items: Some(10), max_bytes: Some(10_000), aggregation_mode: AggregationMode::RawItems, summary_path: Some("$.summary".to_owned()), items_path: Some("$.items".to_owned()), cursor_path: Some("$.cursor".to_owned()), status_path: None, done_path: Some("$.done".to_owned()), redacted_paths: Vec::new(), truncate_item_fields: false, max_field_length: None, drop_duplicates: false, sampling_rate: None, tool_family: ToolFamilyConfig::default(), } } #[test] fn collects_raw_items_mode() { let config = window_config(); let result = collect_window_result( &json!({ "items": [{ "message": "one" }, { "message": "two" }], "summary": { "count": 2 }, "done": true }), &config, ) .unwrap(); assert_eq!(result.summary, Value::Null); assert_eq!(result.items.len(), 2); assert!(result.window_complete); assert!(!result.truncated); assert!(!result.has_more); } #[test] fn builds_summary_only_mode() { let mut config = window_config(); config.aggregation_mode = AggregationMode::SummaryOnly; let result = collect_window_result( &json!({ "items": [{ "message": "one" }], "summary": { "count": 1 }, "done": true }), &config, ) .unwrap(); assert_eq!(result.summary, json!({ "count": 1 })); assert!(result.items.is_empty()); } #[test] fn keeps_summary_plus_samples() { let mut config = window_config(); config.aggregation_mode = AggregationMode::SummaryPlusSamples; let result = collect_window_result( &json!({ "items": [{ "message": "one" }, { "message": "two" }], "summary": { "count": 2 }, "done": true }), &config, ) .unwrap(); assert_eq!(result.summary, json!({ "count": 2 })); assert_eq!(result.items.len(), 2); } #[test] fn truncates_by_item_count() { let mut config = window_config(); config.max_items = Some(1); let result = collect_window_result( &json!({ "items": [{ "message": "one" }, { "message": "two" }], "cursor": "next", "done": false }), &config, ) .unwrap(); assert_eq!(result.items.len(), 1); assert!(result.truncated); assert!(result.has_more); assert!(!result.window_complete); } #[test] fn truncates_by_byte_limit() { let mut config = window_config(); config.max_bytes = Some(120); config.aggregation_mode = AggregationMode::SummaryPlusSamples; let result = collect_window_result( &json!({ "items": [ { "message": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, { "message": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" } ], "summary": { "count": 2 }, "done": true }), &config, ) .unwrap(); assert!(result.truncated); assert!(result.items.len() < 2); } #[test] fn redacts_and_truncates_values() { let mut config = window_config(); config.redacted_paths = vec!["$.secret".to_owned()]; config.truncate_item_fields = true; config.max_field_length = Some(4); let result = collect_window_result( &json!({ "items": [ { "message": "abcdefghijklmnop", "secret": "token" } ], "done": true }), &config, ) .unwrap(); assert_eq!(result.items[0]["secret"], json!("[REDACTED]")); assert_eq!(result.items[0]["message"], json!("abcd...")); } }