Skip to main content

cuenv_core/tasks/
captures.rs

1//! Capture resolution for extracting regex matches from task output.
2
3use super::{CaptureSource, TaskCapture};
4use regex::Regex;
5use std::collections::HashMap;
6
7/// Extract named captures from task stdout/stderr using regex patterns.
8///
9/// Each capture definition specifies a regex pattern with at least one capture group.
10/// The first capture group's match becomes the named value.
11///
12/// Invalid regex patterns or captures without a group 1 match are skipped
13/// with a warning logged via tracing.
14///
15/// Returns a map of capture name -> extracted value.
16pub fn resolve_captures(
17    captures: &HashMap<String, TaskCapture>,
18    stdout: &str,
19    stderr: &str,
20) -> HashMap<String, String> {
21    let mut results = HashMap::new();
22
23    for (name, cap) in captures {
24        let source = match cap.source {
25            CaptureSource::Stdout => stdout,
26            CaptureSource::Stderr => stderr,
27        };
28
29        let re = match Regex::new(&cap.pattern) {
30            Ok(re) => re,
31            Err(err) => {
32                tracing::warn!(
33                    capture = name,
34                    pattern = cap.pattern,
35                    error = %err,
36                    "Failed to compile capture regex"
37                );
38                continue;
39            }
40        };
41
42        match re.captures(source).and_then(|caps| caps.get(1)) {
43            Some(m) => {
44                results.insert(name.clone(), m.as_str().trim().to_string());
45            }
46            None => {
47                tracing::debug!(
48                    capture = name,
49                    pattern = cap.pattern,
50                    "No capture group 1 match found"
51                );
52            }
53        }
54    }
55
56    results
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62
63    fn make_capture(pattern: &str, source: CaptureSource) -> TaskCapture {
64        TaskCapture {
65            pattern: pattern.to_string(),
66            source,
67        }
68    }
69
70    #[test]
71    fn test_resolve_captures_from_stdout() {
72        let captures = HashMap::from([(
73            "previewUrl".to_string(),
74            make_capture(r"Version Preview URL: (.+)", CaptureSource::Stdout),
75        )]);
76
77        let stdout = "Uploading...\nVersion Preview URL: https://example.workers.dev\nDone.";
78
79        let result = resolve_captures(&captures, stdout, "");
80        assert_eq!(
81            result.get("previewUrl").map(String::as_str),
82            Some("https://example.workers.dev")
83        );
84    }
85
86    #[test]
87    fn test_resolve_captures_from_stderr() {
88        let captures = HashMap::from([(
89            "errorCode".to_string(),
90            make_capture(r"Error code: (\d+)", CaptureSource::Stderr),
91        )]);
92
93        let result = resolve_captures(&captures, "", "Error code: 42\nFailed.");
94        assert_eq!(result.get("errorCode").map(String::as_str), Some("42"));
95    }
96
97    #[test]
98    fn test_resolve_captures_no_match() {
99        let captures = HashMap::from([(
100            "missing".to_string(),
101            make_capture(r"not found: (.+)", CaptureSource::Stdout),
102        )]);
103
104        let result = resolve_captures(&captures, "nothing here", "");
105        assert!(result.is_empty());
106    }
107
108    #[test]
109    fn test_resolve_captures_multiple() {
110        let captures = HashMap::from([
111            (
112                "version".to_string(),
113                make_capture(r"version: (.+)", CaptureSource::Stdout),
114            ),
115            (
116                "url".to_string(),
117                make_capture(r"URL: (.+)", CaptureSource::Stdout),
118            ),
119        ]);
120
121        let stdout = "version: 1.2.3\nURL: https://example.com";
122        let result = resolve_captures(&captures, stdout, "");
123        assert_eq!(result.len(), 2);
124        assert_eq!(result.get("version").map(String::as_str), Some("1.2.3"));
125        assert_eq!(
126            result.get("url").map(String::as_str),
127            Some("https://example.com")
128        );
129    }
130
131    #[test]
132    fn test_resolve_captures_empty_output() {
133        let captures = HashMap::from([(
134            "url".to_string(),
135            make_capture(r"URL: (.+)", CaptureSource::Stdout),
136        )]);
137
138        let result = resolve_captures(&captures, "", "");
139        assert!(result.is_empty());
140    }
141
142    #[test]
143    fn test_resolve_captures_invalid_regex() {
144        let captures = HashMap::from([(
145            "bad".to_string(),
146            make_capture(r"[invalid(", CaptureSource::Stdout),
147        )]);
148
149        let result = resolve_captures(&captures, "anything", "");
150        assert!(result.is_empty());
151    }
152
153    #[test]
154    fn test_resolve_captures_trims_whitespace() {
155        let captures = HashMap::from([(
156            "val".to_string(),
157            make_capture(r"value: (.+)", CaptureSource::Stdout),
158        )]);
159
160        let result = resolve_captures(&captures, "value:  hello  ", "");
161        assert_eq!(result.get("val").map(String::as_str), Some("hello"));
162    }
163}