Skip to main content

ready_set_sdk/
lifecycle.rs

1//! Lifecycle protocol helpers for provider plugins.
2
3use std::ffi::OsString;
4
5use crate::CapabilityId;
6
7/// Lifecycle protocol request parsed from plugin argv.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum LifecycleRequest {
10    /// Read-only readiness report request.
11    Ready {
12        /// Capability id to diagnose.
13        capability: CapabilityId,
14    },
15    /// Setup/reconciliation request.
16    Set {
17        /// Capability id to reconcile.
18        capability: CapabilityId,
19        /// Remaining provider-specific arguments.
20        args: Vec<OsString>,
21    },
22    /// Workflow execution request.
23    Go {
24        /// Capability id to execute.
25        capability: CapabilityId,
26        /// Remaining provider-specific arguments.
27        args: Vec<OsString>,
28    },
29}
30
31/// Error produced when a lifecycle protocol request is malformed.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct LifecycleRequestError {
34    message: String,
35}
36
37impl LifecycleRequestError {
38    /// Human-readable error message.
39    #[must_use]
40    pub fn message(&self) -> &str {
41        &self.message
42    }
43}
44
45impl std::fmt::Display for LifecycleRequestError {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        f.write_str(&self.message)
48    }
49}
50
51impl std::error::Error for LifecycleRequestError {}
52
53/// Parse `__ready`, `__set`, or `__go` from a plugin argv iterator.
54///
55/// Returns `Ok(None)` when the first non-program argument is not a lifecycle
56/// protocol command, so the plugin can continue with normal CLI parsing.
57///
58/// # Errors
59///
60/// Returns [`LifecycleRequestError`] when a lifecycle protocol command is
61/// present but missing its required capability argument or, for `__ready`,
62/// includes extra arguments.
63pub fn parse_lifecycle_request(
64    args: impl IntoIterator<Item = OsString>,
65) -> Result<Option<LifecycleRequest>, LifecycleRequestError> {
66    let mut args = args.into_iter();
67    drop(args.next());
68    let Some(command) = args.next() else {
69        return Ok(None);
70    };
71    let Some(command) = command.to_str() else {
72        return Ok(None);
73    };
74
75    match command {
76        "__ready" => {
77            let capability = required_capability(command, args.next())?;
78            if args.next().is_some() {
79                return Err(error("__ready accepts exactly one capability argument"));
80            }
81            Ok(Some(LifecycleRequest::Ready { capability }))
82        },
83        "__set" => {
84            let capability = required_capability(command, args.next())?;
85            Ok(Some(LifecycleRequest::Set {
86                capability,
87                args: args.collect(),
88            }))
89        },
90        "__go" => {
91            let capability = required_capability(command, args.next())?;
92            Ok(Some(LifecycleRequest::Go {
93                capability,
94                args: args.collect(),
95            }))
96        },
97        _ => Ok(None),
98    }
99}
100
101fn required_capability(
102    command: &str,
103    raw: Option<OsString>,
104) -> Result<CapabilityId, LifecycleRequestError> {
105    let Some(raw) = raw else {
106        return Err(error(format!("{command} requires a capability argument")));
107    };
108    let Some(raw) = raw.to_str() else {
109        return Err(error(format!(
110            "{command} capability argument must be valid UTF-8"
111        )));
112    };
113    if raw.is_empty() {
114        return Err(error(format!(
115            "{command} capability argument must not be empty"
116        )));
117    }
118    Ok(CapabilityId::from(raw))
119}
120
121fn error(message: impl Into<String>) -> LifecycleRequestError {
122    LifecycleRequestError {
123        message: message.into(),
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    fn os(args: &[&str]) -> Vec<OsString> {
132        args.iter().map(OsString::from).collect()
133    }
134
135    #[test]
136    fn ignores_non_lifecycle_commands() {
137        assert_eq!(
138            parse_lifecycle_request(os(&["plugin", "run"])).unwrap(),
139            None
140        );
141    }
142
143    #[test]
144    fn parses_ready_request() {
145        let request = parse_lifecycle_request(os(&["plugin", "__ready", "linting"]))
146            .unwrap()
147            .unwrap();
148        assert_eq!(
149            request,
150            LifecycleRequest::Ready {
151                capability: "linting".into()
152            }
153        );
154    }
155
156    #[test]
157    fn parses_set_request_with_passthrough_args() {
158        let request = parse_lifecycle_request(os(&[
159            "plugin",
160            "__set",
161            "formatting",
162            "--dry-run",
163            "--force",
164        ]))
165        .unwrap()
166        .unwrap();
167        assert_eq!(
168            request,
169            LifecycleRequest::Set {
170                capability: "formatting".into(),
171                args: os(&["--dry-run", "--force"]),
172            }
173        );
174    }
175
176    #[test]
177    fn rejects_ready_extra_args() {
178        let err =
179            parse_lifecycle_request(os(&["plugin", "__ready", "linting", "extra"])).unwrap_err();
180        assert!(err.to_string().contains("exactly one"));
181    }
182}