Skip to main content

ready_set_sdk/
capability.rs

1//! Capability lifecycle contract types.
2//!
3//! Typed Rust mirrors of
4//! [`docs/contracts/capabilities.md`](https://github.com/pulsearc-ai/ready-set/blob/main/docs/contracts/capabilities.md).
5
6use std::fmt;
7
8use serde::{Deserialize, Serialize};
9
10/// Stable capability identifier, e.g. `linting` or `release`.
11#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
12#[serde(transparent)]
13pub struct CapabilityId(String);
14
15impl CapabilityId {
16    /// Create a capability id.
17    ///
18    /// Validation is currently handled by the JSON schema contract; this
19    /// constructor stores the string as provided.
20    pub fn new(id: impl Into<String>) -> Self {
21        Self(id.into())
22    }
23
24    /// Borrow the id as a string slice.
25    #[must_use]
26    pub fn as_str(&self) -> &str {
27        &self.0
28    }
29}
30
31impl From<&str> for CapabilityId {
32    fn from(value: &str) -> Self {
33        Self::new(value)
34    }
35}
36
37impl From<String> for CapabilityId {
38    fn from(value: String) -> Self {
39        Self::new(value)
40    }
41}
42
43impl fmt::Display for CapabilityId {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        f.write_str(&self.0)
46    }
47}
48
49impl AsRef<str> for CapabilityId {
50    fn as_ref(&self) -> &str {
51        self.as_str()
52    }
53}
54
55/// Stable provider identifier, usually a plugin subcommand such as `rust`.
56#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
57#[serde(transparent)]
58pub struct ProviderId(String);
59
60impl ProviderId {
61    /// Create a provider id.
62    ///
63    /// Validation is currently handled by the JSON schema contract; this
64    /// constructor stores the string as provided.
65    pub fn new(id: impl Into<String>) -> Self {
66        Self(id.into())
67    }
68
69    /// Borrow the id as a string slice.
70    #[must_use]
71    pub fn as_str(&self) -> &str {
72        &self.0
73    }
74}
75
76impl From<&str> for ProviderId {
77    fn from(value: &str) -> Self {
78        Self::new(value)
79    }
80}
81
82impl From<String> for ProviderId {
83    fn from(value: String) -> Self {
84        Self::new(value)
85    }
86}
87
88impl fmt::Display for ProviderId {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        f.write_str(&self.0)
91    }
92}
93
94impl AsRef<str> for ProviderId {
95    fn as_ref(&self) -> &str {
96        self.as_str()
97    }
98}
99
100/// Lifecycle verb supported by a capability descriptor.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "kebab-case")]
103pub enum CapabilityVerb {
104    /// Read-only readiness diagnosis.
105    Ready,
106    /// Configure or reconcile the capability.
107    Set,
108    /// Execute the capability's main workflow.
109    Go,
110}
111
112/// Readiness state for a capability report.
113///
114/// Variants are pinned by `docs/contracts/capabilities.md` (version 1).
115/// Adding a state is a contract bump, so the enum is intentionally
116/// exhaustive: downstream consumers should match every variant.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(rename_all = "kebab-case")]
119pub enum CapabilityState {
120    /// Present and usable.
121    Ready,
122    /// No implementation or required artifact exists.
123    Missing,
124    /// Present, but not fully configured.
125    Incomplete,
126    /// Cannot be evaluated or run until a dependency is resolved.
127    Blocked,
128    /// Exists, but no longer matches the declared product state.
129    Stale,
130    /// Available but not required for this product.
131    Optional,
132    /// Explicitly irrelevant for this product.
133    NotNeeded,
134}
135
136/// Product relevance for a capability.
137#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(rename_all = "kebab-case")]
139pub enum CapabilityRelevance {
140    /// Required for this product.
141    Required,
142    /// Available but not required.
143    Optional,
144    /// Explicitly irrelevant for this product.
145    NotNeeded,
146}
147
148/// Static metadata for one product capability.
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150pub struct CapabilityDescriptor {
151    /// Stable capability id.
152    pub id: CapabilityId,
153    /// Human label for matrix and help output.
154    pub title: String,
155    /// Stable provider id.
156    pub provider: ProviderId,
157    /// Supported lifecycle verbs.
158    pub verbs: Vec<CapabilityVerb>,
159    /// Default product relevance.
160    pub default_relevance: CapabilityRelevance,
161}
162
163/// Suggested next command for a capability report.
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct NextAction {
166    /// Command the user can run.
167    pub command: String,
168    /// Human description of what the command does.
169    pub description: String,
170}
171
172/// Read-only readiness status for one product capability.
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174pub struct CapabilityReport {
175    /// Stable capability id.
176    pub id: CapabilityId,
177    /// Human label for matrix and help output.
178    pub title: String,
179    /// Stable provider id.
180    pub provider: ProviderId,
181    /// Current readiness state.
182    pub state: CapabilityState,
183    /// Effective product relevance.
184    pub relevance: CapabilityRelevance,
185    /// Short explanation of the current state.
186    pub summary: String,
187    /// Suggested next command, or `None` when no action is needed.
188    pub next_action: Option<NextAction>,
189}
190
191/// Status of a lifecycle run.
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(rename_all = "kebab-case")]
194pub enum RunStatus {
195    /// The run completed successfully without a more specific status.
196    Ok,
197    /// The run changed project state.
198    Changed,
199    /// The run found no work to do.
200    Noop,
201    /// The run failed.
202    Failed,
203}
204
205/// Kind of action included in a lifecycle run report.
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(rename_all = "kebab-case")]
208pub enum CapabilityActionKind {
209    /// Created a filesystem path.
210    Create,
211    /// Modified a filesystem path.
212    Modify,
213    /// Deleted a filesystem path.
214    Delete,
215    /// Ran a command.
216    Run,
217    /// Checked state without modifying it.
218    Check,
219    /// Skipped an action.
220    Skip,
221    /// Encountered an error.
222    Error,
223}
224
225/// One action checked, skipped, executed, or failed during a lifecycle run.
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
227pub struct CapabilityAction {
228    /// Action kind.
229    pub kind: CapabilityActionKind,
230    /// Short action summary.
231    pub summary: String,
232    /// Project-relative path, when the action concerns a filesystem path.
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub path: Option<String>,
235}
236
237/// Structured result from running `set` or `go` for a capability.
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
239pub struct CapabilityRunReport {
240    /// Stable capability id.
241    pub id: CapabilityId,
242    /// Lifecycle verb that ran. Only `set` and `go` are valid on the wire.
243    #[serde(
244        serialize_with = "run_verb::serialize",
245        deserialize_with = "run_verb::deserialize"
246    )]
247    pub verb: CapabilityVerb,
248    /// Overall run status.
249    pub status: RunStatus,
250    /// Ordered actions checked, skipped, executed, or failed.
251    pub actions: Vec<CapabilityAction>,
252}
253
254mod run_verb {
255    use serde::{Deserialize, Deserializer, Serializer};
256
257    use super::CapabilityVerb;
258
259    #[allow(clippy::trivially_copy_pass_by_ref)] // serde `serialize_with` requires &T
260    pub(super) fn serialize<S>(verb: &CapabilityVerb, serializer: S) -> Result<S::Ok, S::Error>
261    where
262        S: Serializer,
263    {
264        use serde::ser::Error as _;
265
266        match verb {
267            CapabilityVerb::Set => serializer.serialize_str("set"),
268            CapabilityVerb::Go => serializer.serialize_str("go"),
269            CapabilityVerb::Ready => Err(S::Error::custom("ready is not valid in a run report")),
270        }
271    }
272
273    pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<CapabilityVerb, D::Error>
274    where
275        D: Deserializer<'de>,
276    {
277        use serde::de::Error as _;
278
279        let raw = String::deserialize(deserializer)?;
280        match raw.as_str() {
281            "set" => Ok(CapabilityVerb::Set),
282            "go" => Ok(CapabilityVerb::Go),
283            other => Err(D::Error::unknown_variant(other, &["set", "go"])),
284        }
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn descriptor_round_trips_json() {
294        let descriptor = CapabilityDescriptor {
295            id: "linting".into(),
296            title: "Linting".into(),
297            provider: "rust".into(),
298            verbs: vec![
299                CapabilityVerb::Ready,
300                CapabilityVerb::Set,
301                CapabilityVerb::Go,
302            ],
303            default_relevance: CapabilityRelevance::Required,
304        };
305
306        let json = serde_json::to_string(&descriptor).unwrap();
307        let back: CapabilityDescriptor = serde_json::from_str(&json).unwrap();
308        assert_eq!(descriptor, back);
309        assert!(json.contains("\"default_relevance\":\"required\""));
310    }
311
312    #[test]
313    fn report_round_trips_json_with_null_next_action() {
314        let report = CapabilityReport {
315            id: "deploy".into(),
316            title: "Deploy".into(),
317            provider: "deploy".into(),
318            state: CapabilityState::NotNeeded,
319            relevance: CapabilityRelevance::NotNeeded,
320            summary: "deployment is not needed".into(),
321            next_action: None,
322        };
323
324        let json = serde_json::to_string(&report).unwrap();
325        let back: CapabilityReport = serde_json::from_str(&json).unwrap();
326        assert_eq!(report, back);
327        assert!(json.contains("\"state\":\"not-needed\""));
328        assert!(json.contains("\"next_action\":null"));
329    }
330
331    #[test]
332    fn run_report_round_trips_json() {
333        let report = CapabilityRunReport {
334            id: "linting".into(),
335            verb: CapabilityVerb::Set,
336            status: RunStatus::Changed,
337            actions: vec![
338                CapabilityAction {
339                    kind: CapabilityActionKind::Create,
340                    summary: "created clippy config".into(),
341                    path: Some("clippy.toml".into()),
342                },
343                CapabilityAction {
344                    kind: CapabilityActionKind::Run,
345                    summary: "ran clippy".into(),
346                    path: None,
347                },
348                CapabilityAction {
349                    kind: CapabilityActionKind::Error,
350                    summary: "clippy failed".into(),
351                    path: None,
352                },
353            ],
354        };
355
356        let json = serde_json::to_string(&report).unwrap();
357        let back: CapabilityRunReport = serde_json::from_str(&json).unwrap();
358        assert_eq!(report, back);
359    }
360
361    #[test]
362    fn run_report_rejects_ready_verb_on_wire() {
363        let report = CapabilityRunReport {
364            id: "linting".into(),
365            verb: CapabilityVerb::Ready,
366            status: RunStatus::Ok,
367            actions: Vec::new(),
368        };
369        assert!(serde_json::to_string(&report).is_err());
370
371        let err = serde_json::from_str::<CapabilityRunReport>(
372            r#"{"id":"linting","verb":"ready","status":"ok","actions":[]}"#,
373        )
374        .unwrap_err();
375        assert!(err.to_string().contains("unknown variant"));
376    }
377}