1use crate::batch::interpolation::VariableStore;
7use crate::batch::BatchOperation;
8use crate::engine::executor::apply_jq_filter;
9use crate::error::Error;
10
11pub fn extract_captures(
20 operation: &BatchOperation,
21 response_body: &str,
22 store: &mut VariableStore,
23) -> Result<(), Error> {
24 let op_id = operation.id.as_deref().unwrap_or("<unnamed>");
25
26 if let Some(captures) = &operation.capture {
27 for (var_name, jq_query) in captures {
28 let value = run_jq_capture(op_id, var_name, jq_query, response_body)?;
29 store.scalars.insert(var_name.clone(), value);
30 }
31 }
32
33 if let Some(appends) = &operation.capture_append {
34 for (list_name, jq_query) in appends {
35 let value = run_jq_capture(op_id, list_name, jq_query, response_body)?;
36 store
37 .lists
38 .entry(list_name.clone())
39 .or_default()
40 .push(value);
41 }
42 }
43
44 Ok(())
45}
46
47fn run_jq_capture(
52 operation_id: &str,
53 var_name: &str,
54 jq_query: &str,
55 response_body: &str,
56) -> Result<String, Error> {
57 let raw = apply_jq_filter(response_body, jq_query)
58 .map_err(|e| Error::batch_capture_failed(operation_id, var_name, e.to_string()))?;
59
60 let trimmed = raw.trim();
61 if trimmed == "null" || trimmed.is_empty() {
62 return Err(Error::batch_capture_failed(
63 operation_id,
64 var_name,
65 format!("JQ query '{jq_query}' returned null or empty"),
66 ));
67 }
68
69 let value = strip_json_quotes(trimmed);
71 if value.is_empty() {
72 return Err(Error::batch_capture_failed(
73 operation_id,
74 var_name,
75 format!("JQ query '{jq_query}' returned null or empty"),
76 ));
77 }
78
79 Ok(value)
80}
81
82fn strip_json_quotes(s: &str) -> String {
88 if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') {
89 return serde_json::from_str::<String>(s).unwrap_or_else(|_| s[1..s.len() - 1].to_string());
90 }
91 s.to_string()
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97 use std::collections::HashMap;
98
99 fn op_with_capture(id: &str, captures: &[(&str, &str)]) -> BatchOperation {
100 BatchOperation {
101 id: Some(id.into()),
102 capture: Some(
103 captures
104 .iter()
105 .map(|(k, v)| ((*k).into(), (*v).into()))
106 .collect(),
107 ),
108 ..Default::default()
109 }
110 }
111
112 fn op_with_capture_append(id: &str, appends: &[(&str, &str)]) -> BatchOperation {
113 BatchOperation {
114 id: Some(id.into()),
115 capture_append: Some(
116 appends
117 .iter()
118 .map(|(k, v)| ((*k).into(), (*v).into()))
119 .collect(),
120 ),
121 ..Default::default()
122 }
123 }
124
125 #[test]
126 fn extract_scalar_from_json_object() {
127 let op = op_with_capture("create-user", &[("user_id", ".id")]);
128 let response = r#"{"id": "abc-123", "name": "Alice"}"#;
129 let mut store = VariableStore::default();
130 extract_captures(&op, response, &mut store).unwrap();
131 assert_eq!(store.scalars.get("user_id").unwrap(), "abc-123");
132 }
133
134 #[test]
135 fn extract_numeric_scalar() {
136 let op = op_with_capture("get-count", &[("count", ".total")]);
137 let response = r#"{"total": 42}"#;
138 let mut store = VariableStore::default();
139 extract_captures(&op, response, &mut store).unwrap();
140 assert_eq!(store.scalars.get("count").unwrap(), "42");
141 }
142
143 #[test]
144 fn extract_string_scalar_unescapes_json_string() {
145 let op = op_with_capture("create-user", &[("user_id", ".id")]);
146 let response = r#"{"id": "a\"b"}"#;
147 let mut store = VariableStore::default();
148
149 extract_captures(&op, response, &mut store).unwrap();
150
151 assert_eq!(store.scalars.get("user_id").unwrap(), "a\"b");
152 }
153
154 #[test]
155 fn extract_nested_field() {
156 let op = op_with_capture("deep", &[("val", ".data.nested.value")]);
157 let response = r#"{"data": {"nested": {"value": "deep-val"}}}"#;
158 let mut store = VariableStore::default();
159 extract_captures(&op, response, &mut store).unwrap();
160 assert_eq!(store.scalars.get("val").unwrap(), "deep-val");
161 }
162
163 #[test]
164 fn capture_append_accumulates_values() {
165 let op1 = op_with_capture_append("beat-1", &[("ids", ".id")]);
166 let op2 = op_with_capture_append("beat-2", &[("ids", ".id")]);
167 let mut store = VariableStore::default();
168
169 extract_captures(&op1, r#"{"id": "first"}"#, &mut store).unwrap();
170 extract_captures(&op2, r#"{"id": "second"}"#, &mut store).unwrap();
171
172 let list = store.lists.get("ids").unwrap();
173 assert_eq!(list, &["first", "second"]);
174 }
175
176 #[test]
177 fn null_capture_returns_error() {
178 let op = op_with_capture("test-op", &[("val", ".missing_field")]);
179 let response = r#"{"other": "data"}"#;
180 let mut store = VariableStore::default();
181 let result = extract_captures(&op, response, &mut store);
182 assert!(result.is_err());
183 let err = result.unwrap_err().to_string();
184 assert!(
185 err.contains("null or empty"),
186 "expected null error, got: {err}"
187 );
188 }
189
190 #[test]
191 fn empty_string_capture_returns_error() {
192 let op = op_with_capture("test-op", &[("val", ".id")]);
193 let response = r#"{"id": ""}"#;
194 let mut store = VariableStore::default();
195
196 let result = extract_captures(&op, response, &mut store);
197
198 assert!(result.is_err());
199 let err = result.unwrap_err().to_string();
200 assert!(
201 err.contains("null or empty"),
202 "expected empty-capture error, got: {err}"
203 );
204 }
205
206 #[test]
207 fn invalid_jq_returns_error() {
208 let op = op_with_capture("test-op", &[("val", "invalid..query")]);
209 let response = r#"{"id": "test"}"#;
210 let mut store = VariableStore::default();
211 let result = extract_captures(&op, response, &mut store);
212 assert!(result.is_err());
214 }
215
216 #[test]
217 fn mixed_capture_and_append() {
218 let op = BatchOperation {
219 id: Some("mixed".into()),
220 capture: Some(HashMap::from([("scalar_id".into(), ".id".into())])),
221 capture_append: Some(HashMap::from([("list_ids".into(), ".id".into())])),
222 ..Default::default()
223 };
224 let mut store = VariableStore::default();
225 extract_captures(&op, r#"{"id": "val-1"}"#, &mut store).unwrap();
226 assert_eq!(store.scalars.get("scalar_id").unwrap(), "val-1");
227 assert_eq!(store.lists.get("list_ids").unwrap(), &["val-1"]);
228 }
229
230 #[test]
231 fn no_captures_is_noop() {
232 let op = BatchOperation {
233 id: Some("plain".into()),
234 ..Default::default()
235 };
236 let mut store = VariableStore::default();
237 extract_captures(&op, r#"{"id": "test"}"#, &mut store).unwrap();
238 assert!(store.scalars.is_empty());
239 assert!(store.lists.is_empty());
240 }
241}