greentic_conformance/
component_suite.rs1use std::{
2 path::{Path, PathBuf},
3 process::Command,
4};
5
6use anyhow::{bail, Context, Result};
7use serde_json::Value;
8
9#[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 pub fn add_arg(mut self, arg: impl Into<String>) -> Self {
32 self.args.push(arg.into());
33 self
34 }
35
36 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 pub fn with_working_dir(mut self, dir: impl Into<PathBuf>) -> Self {
44 self.working_dir = Some(dir.into());
45 self
46 }
47
48 pub fn allow_non_json_output(mut self) -> Self {
50 self.expect_json_output = false;
51 self
52 }
53}
54
55#[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
66pub 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 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}