2026-04-06 16:41:55 +01:00
|
|
|
use std::collections::HashMap;
|
|
|
|
|
|
|
|
|
|
/// Parse `##wfe[output key=value]` lines from stdout.
|
|
|
|
|
///
|
|
|
|
|
/// This is the same output protocol used by wfe-containerd and wfe-yaml shell steps.
|
|
|
|
|
pub fn parse_outputs(stdout: &str) -> HashMap<String, String> {
|
|
|
|
|
let mut outputs = HashMap::new();
|
|
|
|
|
for line in stdout.lines() {
|
|
|
|
|
let trimmed = line.trim();
|
|
|
|
|
if let Some(inner) = trimmed
|
|
|
|
|
.strip_prefix("##wfe[output ")
|
|
|
|
|
.and_then(|s| s.strip_suffix(']'))
|
|
|
|
|
{
|
|
|
|
|
if let Some(eq_pos) = inner.find('=') {
|
|
|
|
|
let key = inner[..eq_pos].trim().to_string();
|
|
|
|
|
let value = inner[eq_pos + 1..].to_string();
|
|
|
|
|
if !key.is_empty() {
|
|
|
|
|
outputs.insert(key, value);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
outputs
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Build the output_data JSON object from step execution results.
|
|
|
|
|
///
|
|
|
|
|
/// Includes parsed `##wfe[output]` values plus raw stdout/stderr/exit_code
|
|
|
|
|
/// prefixed with the step name.
|
|
|
|
|
pub fn build_output_data(
|
|
|
|
|
step_name: &str,
|
|
|
|
|
stdout: &str,
|
|
|
|
|
stderr: &str,
|
|
|
|
|
exit_code: i32,
|
|
|
|
|
parsed: &HashMap<String, String>,
|
|
|
|
|
) -> serde_json::Value {
|
|
|
|
|
let mut map = serde_json::Map::new();
|
|
|
|
|
|
|
|
|
|
// Add parsed outputs.
|
|
|
|
|
for (key, value) in parsed {
|
|
|
|
|
// Try to parse as JSON value, fall back to string.
|
|
|
|
|
let json_val = serde_json::from_str(value)
|
|
|
|
|
.unwrap_or_else(|_| serde_json::Value::String(value.clone()));
|
|
|
|
|
map.insert(key.clone(), json_val);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add raw stdout/stderr/exit_code with step name prefix.
|
|
|
|
|
map.insert(
|
|
|
|
|
format!("{step_name}.stdout"),
|
|
|
|
|
serde_json::Value::String(stdout.to_string()),
|
|
|
|
|
);
|
|
|
|
|
map.insert(
|
|
|
|
|
format!("{step_name}.stderr"),
|
|
|
|
|
serde_json::Value::String(stderr.to_string()),
|
|
|
|
|
);
|
|
|
|
|
map.insert(
|
|
|
|
|
format!("{step_name}.exit_code"),
|
|
|
|
|
serde_json::Value::Number(exit_code.into()),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
serde_json::Value::Object(map)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use pretty_assertions::assert_eq;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_empty_stdout() {
|
|
|
|
|
assert!(parse_outputs("").is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_no_output_markers() {
|
|
|
|
|
let stdout = "hello world\nsome other output\n";
|
|
|
|
|
assert!(parse_outputs(stdout).is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_single_output() {
|
|
|
|
|
let stdout = "building...\n##wfe[output version=1.2.3]\ndone\n";
|
|
|
|
|
let outputs = parse_outputs(stdout);
|
|
|
|
|
assert_eq!(outputs.get("version"), Some(&"1.2.3".to_string()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_multiple_outputs() {
|
|
|
|
|
let stdout = "##wfe[output digest=sha256:abc]\n##wfe[output tag=latest]\n";
|
|
|
|
|
let outputs = parse_outputs(stdout);
|
|
|
|
|
assert_eq!(outputs.len(), 2);
|
|
|
|
|
assert_eq!(outputs.get("digest"), Some(&"sha256:abc".to_string()));
|
|
|
|
|
assert_eq!(outputs.get("tag"), Some(&"latest".to_string()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_value_with_equals() {
|
|
|
|
|
let stdout = "##wfe[output url=https://example.com?a=1&b=2]\n";
|
|
|
|
|
let outputs = parse_outputs(stdout);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
outputs.get("url"),
|
|
|
|
|
Some(&"https://example.com?a=1&b=2".to_string())
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_duplicate_key_last_wins() {
|
|
|
|
|
let stdout = "##wfe[output key=first]\n##wfe[output key=second]\n";
|
|
|
|
|
let outputs = parse_outputs(stdout);
|
|
|
|
|
assert_eq!(outputs.get("key"), Some(&"second".to_string()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_whitespace_in_key() {
|
|
|
|
|
let stdout = "##wfe[output key = value]\n";
|
|
|
|
|
let outputs = parse_outputs(stdout);
|
|
|
|
|
assert_eq!(outputs.get("key"), Some(&" value".to_string()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_empty_value() {
|
|
|
|
|
let stdout = "##wfe[output key=]\n";
|
|
|
|
|
let outputs = parse_outputs(stdout);
|
|
|
|
|
assert_eq!(outputs.get("key"), Some(&"".to_string()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_ignores_malformed() {
|
|
|
|
|
let stdout = "##wfe[output ]\n##wfe[output no_equals]\n##wfe[output =no_key]\n";
|
|
|
|
|
let outputs = parse_outputs(stdout);
|
|
|
|
|
// "=no_key" has empty key, ignored. "no_equals" has no =, ignored. " " has no =, ignored.
|
|
|
|
|
assert!(outputs.is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn build_output_data_with_parsed() {
|
|
|
|
|
let parsed: HashMap<String, String> =
|
|
|
|
|
[("version".into(), "1.0.0".into())].into_iter().collect();
|
|
|
|
|
let data = build_output_data("test", "hello\n", "warn\n", 0, &parsed);
|
|
|
|
|
assert_eq!(data["version"], "1.0.0");
|
|
|
|
|
assert_eq!(data["test.stdout"], "hello\n");
|
|
|
|
|
assert_eq!(data["test.stderr"], "warn\n");
|
|
|
|
|
assert_eq!(data["test.exit_code"], 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn build_output_data_empty() {
|
|
|
|
|
let data = build_output_data("step", "", "", 0, &HashMap::new());
|
|
|
|
|
assert_eq!(data["step.stdout"], "");
|
|
|
|
|
assert_eq!(data["step.exit_code"], 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn build_output_data_json_value() {
|
2026-04-07 18:44:21 +01:00
|
|
|
let parsed: HashMap<String, String> = [
|
|
|
|
|
("count".into(), "42".into()),
|
|
|
|
|
("flag".into(), "true".into()),
|
|
|
|
|
]
|
|
|
|
|
.into_iter()
|
|
|
|
|
.collect();
|
2026-04-06 16:41:55 +01:00
|
|
|
let data = build_output_data("s", "", "", 0, &parsed);
|
|
|
|
|
// Numbers and booleans should be parsed as JSON, not strings.
|
|
|
|
|
assert_eq!(data["count"], 42);
|
|
|
|
|
assert_eq!(data["flag"], true);
|
|
|
|
|
}
|
|
|
|
|
}
|