1use std::ffi::OsString;
4use std::process::{Command, ExitStatus, Stdio};
5
6use ready_set_sdk::{CapabilityReport, CapabilityRunReport, ExitCode, ProviderId};
7
8use crate::discovery::find_plugin;
9use crate::env::{EnvContract, export_contract};
10
11#[derive(Debug)]
13pub enum ReadyInvocation {
14 Report(CapabilityReport),
16 ProviderUnavailable {
18 summary: String,
20 },
21 ProviderFailed {
23 summary: String,
25 },
26}
27
28#[derive(Debug)]
30pub enum SetInvocation {
31 Report(CapabilityRunReport),
33 Streamed {
35 exit_code: ExitCode,
37 },
38 ProviderUnavailable {
40 summary: String,
42 },
43 ProviderFailed {
45 exit_code: ExitCode,
47 summary: String,
49 },
50}
51
52#[derive(Debug)]
54pub enum GoInvocation {
55 Report {
57 report: CapabilityRunReport,
59 exit_code: ExitCode,
61 },
62 Streamed {
64 exit_code: ExitCode,
66 },
67 ProviderUnavailable {
69 summary: String,
71 },
72 ProviderFailed {
74 exit_code: ExitCode,
76 summary: String,
78 },
79}
80
81pub fn invoke_ready(
87 provider: &ProviderId,
88 capability: &str,
89 contract: &EnvContract,
90) -> std::io::Result<ReadyInvocation> {
91 let Some(entry) = find_plugin(provider.as_str()) else {
92 return Ok(ReadyInvocation::ProviderUnavailable {
93 summary: format!("provider `{provider}` is not installed"),
94 });
95 };
96
97 let output = command_for_provider(&entry.binary_path, contract)
98 .arg("__ready")
99 .arg(capability)
100 .env("READY_SET_OUTPUT", "json")
101 .stdin(Stdio::null())
102 .stdout(Stdio::piped())
103 .stderr(Stdio::piped())
104 .output()?;
105
106 if !output.status.success() {
107 return Ok(ReadyInvocation::ProviderFailed {
108 summary: provider_failure_summary(provider, "__ready", &output.stderr),
109 });
110 }
111
112 let stdout = String::from_utf8_lossy(&output.stdout);
113 match serde_json::from_str::<CapabilityReport>(stdout.trim()) {
114 Ok(report) => Ok(ReadyInvocation::Report(report)),
115 Err(err) => Ok(ReadyInvocation::ProviderFailed {
116 summary: format!("provider `{provider}` returned invalid readiness JSON: {err}"),
117 }),
118 }
119}
120
121pub fn invoke_set(
130 provider: &ProviderId,
131 capability: &str,
132 args: &[OsString],
133 contract: &EnvContract,
134 capture_json: bool,
135) -> std::io::Result<SetInvocation> {
136 let Some(entry) = find_plugin(provider.as_str()) else {
137 return Ok(SetInvocation::ProviderUnavailable {
138 summary: format!("provider `{provider}` is not installed"),
139 });
140 };
141
142 let mut cmd = command_for_provider(&entry.binary_path, contract);
143 cmd.arg("__set").arg(capability).args(args);
144
145 if !capture_json {
146 let status = cmd.status()?;
147 return Ok(SetInvocation::Streamed {
148 exit_code: exit_code_from_status(status),
149 });
150 }
151
152 let output = cmd
153 .env("READY_SET_OUTPUT", "json")
154 .stdin(Stdio::null())
155 .stdout(Stdio::piped())
156 .stderr(Stdio::piped())
157 .output()?;
158 let exit_code = exit_code_from_status(output.status);
159 let stdout = String::from_utf8_lossy(&output.stdout);
160 match serde_json::from_str::<CapabilityRunReport>(stdout.trim()) {
161 Ok(report) if output.status.success() => Ok(SetInvocation::Report(report)),
162 Ok(_) => Ok(SetInvocation::ProviderFailed {
163 exit_code,
164 summary: provider_failure_summary(provider, "__set", &output.stderr),
165 }),
166 Err(err) => Ok(SetInvocation::ProviderFailed {
167 exit_code: ExitCode::ContractViolation,
168 summary: format!("provider `{provider}` returned invalid set JSON: {err}"),
169 }),
170 }
171}
172
173pub fn invoke_go(
183 provider: &ProviderId,
184 capability: &str,
185 args: &[OsString],
186 contract: &EnvContract,
187 capture_json: bool,
188) -> std::io::Result<GoInvocation> {
189 let Some(entry) = find_plugin(provider.as_str()) else {
190 return Ok(GoInvocation::ProviderUnavailable {
191 summary: format!("provider `{provider}` is not installed"),
192 });
193 };
194
195 let mut cmd = command_for_provider(&entry.binary_path, contract);
196 cmd.arg("__go").arg(capability).args(args);
197
198 if !capture_json {
199 let status = cmd.status()?;
200 return Ok(GoInvocation::Streamed {
201 exit_code: exit_code_from_status(status),
202 });
203 }
204
205 let output = cmd
206 .env("READY_SET_OUTPUT", "json")
207 .stdin(Stdio::null())
208 .stdout(Stdio::piped())
209 .stderr(Stdio::piped())
210 .output()?;
211 let exit_code = exit_code_from_status(output.status);
212 let stdout = String::from_utf8_lossy(&output.stdout);
213 match serde_json::from_str::<CapabilityRunReport>(stdout.trim()) {
214 Ok(report) => Ok(GoInvocation::Report { report, exit_code }),
215 Err(err) => Ok(GoInvocation::ProviderFailed {
216 exit_code: ExitCode::ContractViolation,
217 summary: format!("provider `{provider}` returned invalid go JSON: {err}"),
218 }),
219 }
220}
221
222fn command_for_provider(binary: &std::path::Path, contract: &EnvContract) -> Command {
223 let mut cmd = Command::new(binary);
224 export_contract(&mut cmd, contract);
225 cmd
226}
227
228fn provider_failure_summary(provider: &ProviderId, command: &str, stderr: &[u8]) -> String {
229 let stderr = String::from_utf8_lossy(stderr);
230 let detail = stderr.trim();
231 if detail.is_empty() {
232 format!("provider `{provider}` {command} failed")
233 } else {
234 format!("provider `{provider}` {command} failed: {detail}")
235 }
236}
237
238#[allow(clippy::match_same_arms)]
241fn exit_code_from_status(status: ExitStatus) -> ExitCode {
242 if let Some(code) = status.code() {
243 return match code {
244 0 => ExitCode::Ok,
245 1 => ExitCode::UserError,
246 2 => ExitCode::SystemError,
247 3 => ExitCode::DependencyMissing,
248 4 => ExitCode::NotCargoWorkspace,
249 5 => ExitCode::ContractViolation,
250 127 => ExitCode::UnknownSubcommand,
251 _ => ExitCode::SystemError,
252 };
253 }
254 signaled_or_system(status)
255}
256
257#[cfg(unix)]
258fn signaled_or_system(status: ExitStatus) -> ExitCode {
259 use std::os::unix::process::ExitStatusExt;
260 status
261 .signal()
262 .and_then(|s| u8::try_from(s).ok())
263 .map_or(ExitCode::SystemError, ExitCode::Signaled)
264}
265
266#[cfg(not(unix))]
267const fn signaled_or_system(_status: ExitStatus) -> ExitCode {
268 ExitCode::SystemError
269}
270
271#[cfg(all(test, unix))]
272mod tests {
273 use std::os::unix::process::ExitStatusExt;
274
275 use super::*;
276
277 fn exited(code: i32) -> ExitStatus {
280 ExitStatus::from_raw(code << 8)
281 }
282
283 fn signaled(signum: i32) -> ExitStatus {
286 ExitStatus::from_raw(signum)
287 }
288
289 #[test]
290 fn maps_documented_exit_codes() {
291 assert_eq!(exit_code_from_status(exited(0)), ExitCode::Ok);
292 assert_eq!(exit_code_from_status(exited(1)), ExitCode::UserError);
293 assert_eq!(exit_code_from_status(exited(2)), ExitCode::SystemError);
294 assert_eq!(
295 exit_code_from_status(exited(3)),
296 ExitCode::DependencyMissing
297 );
298 assert_eq!(
299 exit_code_from_status(exited(4)),
300 ExitCode::NotCargoWorkspace
301 );
302 assert_eq!(
303 exit_code_from_status(exited(5)),
304 ExitCode::ContractViolation
305 );
306 assert_eq!(
307 exit_code_from_status(exited(127)),
308 ExitCode::UnknownSubcommand
309 );
310 }
311
312 #[test]
313 fn unrecognized_exit_code_falls_back_to_system_error() {
314 assert_eq!(exit_code_from_status(exited(99)), ExitCode::SystemError);
315 assert_eq!(exit_code_from_status(exited(42)), ExitCode::SystemError);
316 }
317
318 #[test]
319 fn maps_signals_to_signaled_variant() {
320 assert_eq!(exit_code_from_status(signaled(2)), ExitCode::Signaled(2));
322 assert_eq!(exit_code_from_status(signaled(9)), ExitCode::Signaled(9));
324 assert_eq!(exit_code_from_status(signaled(15)), ExitCode::Signaled(15));
326 }
327
328 #[test]
329 fn signaled_emits_posix_shell_exit_code() {
330 assert_eq!(exit_code_from_status(signaled(2)).as_u8(), 130);
332 assert_eq!(exit_code_from_status(signaled(15)).as_u8(), 143);
333 }
334}