Skip to main content

ready_set/
lifecycle.rs

1//! Core lifecycle dispatch to provider plugins.
2
3use 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/// Result of invoking a provider `__ready` command.
12#[derive(Debug)]
13pub enum ReadyInvocation {
14    /// Provider returned a valid report.
15    Report(CapabilityReport),
16    /// No provider binary was found.
17    ProviderUnavailable {
18        /// Human-readable summary.
19        summary: String,
20    },
21    /// The provider ran but did not return a usable readiness report.
22    ProviderFailed {
23        /// Human-readable summary.
24        summary: String,
25    },
26}
27
28/// Result of invoking a provider `__set` command.
29#[derive(Debug)]
30pub enum SetInvocation {
31    /// Provider completed and returned a report.
32    Report(CapabilityRunReport),
33    /// Human-mode provider process exited with a code.
34    Streamed {
35        /// Mapped dispatcher exit code.
36        exit_code: ExitCode,
37    },
38    /// No provider binary was found.
39    ProviderUnavailable {
40        /// Human-readable summary.
41        summary: String,
42    },
43    /// The provider ran but did not return a usable run report.
44    ProviderFailed {
45        /// Mapped dispatcher exit code.
46        exit_code: ExitCode,
47        /// Human-readable summary.
48        summary: String,
49    },
50}
51
52/// Result of invoking a provider `__go` command.
53#[derive(Debug)]
54pub enum GoInvocation {
55    /// Provider completed and returned a report.
56    Report {
57        /// Structured run report.
58        report: CapabilityRunReport,
59        /// Mapped dispatcher exit code.
60        exit_code: ExitCode,
61    },
62    /// Human-mode provider process exited with a code.
63    Streamed {
64        /// Mapped dispatcher exit code.
65        exit_code: ExitCode,
66    },
67    /// No provider binary was found.
68    ProviderUnavailable {
69        /// Human-readable summary.
70        summary: String,
71    },
72    /// The provider ran but did not return a usable run report.
73    ProviderFailed {
74        /// Mapped dispatcher exit code.
75        exit_code: ExitCode,
76        /// Human-readable summary.
77        summary: String,
78    },
79}
80
81/// Invoke `<provider> __ready <capability>` and parse its JSON report.
82///
83/// # Errors
84///
85/// Returns I/O errors from spawning the provider process.
86pub 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
121/// Invoke `<provider> __set <capability> ...`.
122///
123/// When `capture_json` is true, stdout is parsed as a
124/// [`CapabilityRunReport`]. Otherwise stdout/stderr stream directly.
125///
126/// # Errors
127///
128/// Returns I/O errors from spawning the provider process.
129pub 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
173/// Invoke `<provider> __go <capability> ...`.
174///
175/// When `capture_json` is true, stdout is parsed as a
176/// [`CapabilityRunReport`]. Unlike `set`, a nonzero provider exit can still
177/// return a valid failed workflow report.
178///
179/// # Errors
180///
181/// Returns I/O errors from spawning the provider process.
182pub 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// Each arm documents one row of `docs/contracts/exit-codes.md`; `Some(2)` is
239// kept explicit even though the catch-all also returns `SystemError`.
240#[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    // Construct a synthetic ExitStatus for a normal exit with `code`.
278    // Linux/macOS waitpid status: bits 8-15 hold the exit code; bits 0-6 are 0.
279    fn exited(code: i32) -> ExitStatus {
280        ExitStatus::from_raw(code << 8)
281    }
282
283    // Construct a synthetic ExitStatus for a process killed by `signum`.
284    // bits 0-6 hold the signal number; bits 8-15 are 0.
285    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        // SIGINT
321        assert_eq!(exit_code_from_status(signaled(2)), ExitCode::Signaled(2));
322        // SIGKILL
323        assert_eq!(exit_code_from_status(signaled(9)), ExitCode::Signaled(9));
324        // SIGTERM
325        assert_eq!(exit_code_from_status(signaled(15)), ExitCode::Signaled(15));
326    }
327
328    #[test]
329    fn signaled_emits_posix_shell_exit_code() {
330        // POSIX convention: shell reports 128 + signum.
331        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}