greentic_conformance/
component_suite.rs

1use std::{
2    path::{Path, PathBuf},
3    process::Command,
4};
5
6use anyhow::{bail, Context, Result};
7use serde_json::Value;
8
9/// Options that tune how a component invocation is performed.
10#[derive(Debug, Clone)]
11pub struct ComponentInvocationOptions {
12    pub args: Vec<String>,
13    pub env: Vec<(String, String)>,
14    pub working_dir: Option<PathBuf>,
15    pub expect_json_output: bool,
16}
17
18impl Default for ComponentInvocationOptions {
19    fn default() -> Self {
20        Self {
21            args: Vec::new(),
22            env: Vec::new(),
23            working_dir: None,
24            expect_json_output: true,
25        }
26    }
27}
28
29impl ComponentInvocationOptions {
30    /// Appends a pass-through argument for the component invocation.
31    pub fn add_arg(mut self, arg: impl Into<String>) -> Self {
32        self.args.push(arg.into());
33        self
34    }
35
36    /// Adds an environment variable to the invocation context.
37    pub fn add_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
38        self.env.push((key.into(), value.into()));
39        self
40    }
41
42    /// Sets the working directory that will be used when spawning the component.
43    pub fn with_working_dir(mut self, dir: impl Into<PathBuf>) -> Self {
44        self.working_dir = Some(dir.into());
45        self
46    }
47
48    /// Turns off the JSON stdout assertion.
49    pub fn allow_non_json_output(mut self) -> Self {
50        self.expect_json_output = false;
51        self
52    }
53}
54
55/// Result produced after invoking a component.
56#[derive(Debug, Clone)]
57pub struct ComponentInvocation {
58    pub component: PathBuf,
59    pub operation: String,
60    pub stdout: String,
61    pub stderr: String,
62    pub status: i32,
63    pub output_json: Option<Value>,
64}
65
66/// Invokes a generic component using the default options.
67pub fn invoke_generic_component(
68    component_path: &str,
69    op: &str,
70    input_json: &str,
71) -> Result<ComponentInvocation> {
72    ComponentInvocationOptions::default().invoke_generic_component(component_path, op, input_json)
73}
74
75impl ComponentInvocationOptions {
76    pub fn invoke_generic_component(
77        self,
78        component_path: impl AsRef<Path>,
79        op: &str,
80        input_json: &str,
81    ) -> Result<ComponentInvocation> {
82        let component_path = component_path.as_ref();
83        if !component_path.exists() {
84            bail!(
85                "component binary '{}' does not exist",
86                component_path.display()
87            );
88        }
89
90        // Ensure the input payload is valid JSON up-front so that a harness failure
91        // is reported clearly.
92        let parsed_input: Value = serde_json::from_str(input_json).with_context(|| {
93            format!(
94                "component input payload is not valid JSON for operation '{}'",
95                op
96            )
97        })?;
98
99        let mut command = Command::new(component_path);
100        command.arg(op);
101        for extra_arg in &self.args {
102            command.arg(extra_arg);
103        }
104
105        command.env("GREENTIC_COMPONENT_OPERATION", op);
106        command.env("GREENTIC_CONFORMANCE", "1");
107
108        for (key, value) in &self.env {
109            command.env(key, value);
110        }
111
112        if let Some(dir) = &self.working_dir {
113            command.current_dir(dir);
114        }
115
116        let mut child = command
117            .stdin(std::process::Stdio::piped())
118            .stdout(std::process::Stdio::piped())
119            .stderr(std::process::Stdio::piped())
120            .spawn()
121            .with_context(|| {
122                format!(
123                    "failed to spawn component '{}' for operation '{}'",
124                    component_path.display(),
125                    op
126                )
127            })?;
128
129        if let Some(stdin) = &mut child.stdin {
130            use std::io::Write;
131            stdin
132                .write_all(parsed_input.to_string().as_bytes())
133                .context("failed to write component stdin payload")?;
134        }
135
136        let output = child.wait_with_output().with_context(|| {
137            format!("component '{}' invocation failed", component_path.display())
138        })?;
139
140        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
141        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
142        let status = output.status.code().unwrap_or_default();
143
144        if !output.status.success() {
145            bail!(
146                "component '{}' operation '{}' failed with status {}.\nstdout:\n{}\nstderr:\n{}",
147                component_path.display(),
148                op,
149                status,
150                stdout,
151                stderr
152            );
153        }
154
155        let output_json = if self.expect_json_output {
156            Some(serde_json::from_str(stdout.trim()).with_context(|| {
157                format!(
158                    "component '{}' stdout is not valid JSON for operation '{}'",
159                    component_path.display(),
160                    op
161                )
162            })?)
163        } else {
164            None
165        };
166
167        Ok(ComponentInvocation {
168            component: component_path.to_path_buf(),
169            operation: op.to_string(),
170            stdout,
171            stderr,
172            status,
173            output_json,
174        })
175    }
176}