Skip to main content

aperture_cli/batch/
capture.rs

1//! JQ-based value extraction from operation responses.
2//!
3//! Applies JQ queries from `capture` and `capture_append` fields to an
4//! operation's response body and stores results in the [`VariableStore`].
5
6use crate::batch::interpolation::VariableStore;
7use crate::batch::BatchOperation;
8use crate::engine::executor::apply_jq_filter;
9use crate::error::Error;
10
11/// Extracts captured values from a response and stores them in the variable store.
12///
13/// For `capture` entries, the JQ result is stored as a scalar string.
14/// For `capture_append` entries, the JQ result is appended to a list.
15///
16/// # Errors
17///
18/// Returns an error if JQ evaluation fails or produces no output.
19pub 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
47/// Runs a single JQ query and returns the extracted string value.
48///
49/// Strips surrounding quotes from JSON string results so that
50/// interpolation produces clean values (e.g. `abc-123` not `"abc-123"`).
51fn 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    // Strip surrounding quotes from JSON string values.
70    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
82/// Converts JQ output into the scalar representation used by interpolation.
83///
84/// If the output is a JSON string literal, decode it so escape sequences are
85/// interpreted (`"a\\\"b"` → `a"b`). Non-string JSON values are preserved
86/// as their textual representation (`42` → `42`, `true` → `true`).
87fn 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        // Should error — either from jq parsing or from our validation
213        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}