Skip to main content

ready_set_sdk/
describe.rs

1//! `__describe` subcommand support.
2//!
3//! See
4//! [`docs/contracts/describe.md`](https://github.com/pulsearc-ai/ready-set/blob/main/docs/contracts/describe.md)
5//! for the source of truth.
6
7use std::ffi::OsString;
8use std::io::Write;
9
10use serde::{Deserialize, Serialize};
11
12use crate::capability::CapabilityDescriptor;
13use crate::error::{Error, Result};
14use crate::exit_code::ExitCode;
15
16/// Stability tier reported by `__describe` and the manifest sidecar.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "lowercase")]
19pub enum Stability {
20    /// Stable, follows semver.
21    Stable,
22    /// May break in any release.
23    Experimental,
24    /// Slated for removal.
25    Deprecated,
26}
27
28/// Operating system enumeration.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "lowercase")]
31pub enum Platform {
32    /// Linux family.
33    Linux,
34    /// macOS.
35    Macos,
36    /// Windows.
37    Windows,
38}
39
40impl Platform {
41    /// The platform of the running binary, if known.
42    #[must_use]
43    pub const fn current() -> Option<Self> {
44        if cfg!(target_os = "linux") {
45            Some(Self::Linux)
46        } else if cfg!(target_os = "macos") {
47            Some(Self::Macos)
48        } else if cfg!(target_os = "windows") {
49            Some(Self::Windows)
50        } else {
51            None
52        }
53    }
54}
55
56/// Plugin self-description payload.
57///
58/// Same shape as [`crate::manifest::Manifest`]; the two share a JSON schema.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct Describe {
61    /// One-line summary, max 80 chars.
62    pub description: String,
63    /// Plugin semver.
64    pub version: semver::Version,
65    /// Stability tier.
66    pub stability: Stability,
67    /// Minimum dispatcher semver this plugin requires.
68    pub min_dispatcher_version: semver::Version,
69    /// Supported operating systems.
70    pub platforms: Vec<Platform>,
71    /// Whether the plugin requires a cargo workspace context.
72    pub requires_cargo_workspace: bool,
73    /// Product capabilities contributed by this plugin.
74    pub capabilities: Vec<CapabilityDescriptor>,
75}
76
77impl Describe {
78    /// Print this `Describe` as a single line of JSON to stdout, terminated
79    /// by a newline.
80    ///
81    /// # Errors
82    ///
83    /// Returns [`Error::JsonParse`] if serialization fails or [`Error::Io`]
84    /// if writing to stdout fails.
85    pub fn emit_stdout(&self) -> Result<()> {
86        let line = serde_json::to_string(self)?;
87        let stdout = std::io::stdout();
88        let mut handle = stdout.lock();
89        writeln!(handle, "{line}").map_err(Error::Io)?;
90        handle.flush().map_err(Error::Io)?;
91        Ok(())
92    }
93
94    /// If the first non-program argument is `__describe`, emit this `Describe`
95    /// and return `Some(ExitCode::Ok)`. Otherwise return `None` so the caller
96    /// can continue with normal argument parsing.
97    ///
98    /// Plugins MUST call this before their main argument parser runs to
99    /// avoid clap rejecting the unknown subcommand.
100    ///
101    /// # Errors
102    ///
103    /// Returns `Some(ExitCode::ContractViolation)` if `__describe` is given
104    /// extra arguments, or if emitting the JSON line fails. Returns
105    /// `Some(ExitCode::Ok)` on the happy path.
106    pub fn handle_arg0_describe(
107        &self,
108        args: impl IntoIterator<Item = OsString>,
109    ) -> Option<ExitCode> {
110        let mut iter = args.into_iter();
111        // Skip program name (argv[0]).
112        drop(iter.next());
113        let first = iter.next()?;
114        if first != "__describe" {
115            return None;
116        }
117        if iter.next().is_some() {
118            // Extra args after __describe violate the contract.
119            eprintln!("__describe accepts no extra arguments");
120            return Some(ExitCode::ContractViolation);
121        }
122        match self.emit_stdout() {
123            Ok(()) => Some(ExitCode::Ok),
124            Err(_) => Some(ExitCode::ContractViolation),
125        }
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    fn fixture() -> Describe {
134        Describe {
135            description: "test".into(),
136            version: semver::Version::new(0, 1, 0),
137            stability: Stability::Stable,
138            min_dispatcher_version: semver::Version::new(0, 1, 0),
139            platforms: vec![Platform::Linux, Platform::Macos, Platform::Windows],
140            requires_cargo_workspace: false,
141            capabilities: Vec::new(),
142        }
143    }
144
145    #[test]
146    fn skips_when_no_describe_arg() {
147        let d = fixture();
148        let args: Vec<OsString> = ["prog", "run", "--flag"]
149            .iter()
150            .map(OsString::from)
151            .collect();
152        let result = d.handle_arg0_describe(args);
153        assert!(result.is_none());
154    }
155
156    #[test]
157    fn skips_when_no_args_at_all() {
158        let d = fixture();
159        let result = d.handle_arg0_describe([OsString::from("prog")]);
160        assert!(result.is_none());
161    }
162
163    #[test]
164    fn rejects_extra_args() {
165        let d = fixture();
166        let args: Vec<OsString> = ["prog", "__describe", "extra"]
167            .iter()
168            .map(OsString::from)
169            .collect();
170        let result = d.handle_arg0_describe(args);
171        assert_eq!(result, Some(ExitCode::ContractViolation));
172    }
173
174    #[test]
175    fn json_round_trip() {
176        let d = fixture();
177        let json = serde_json::to_string(&d).unwrap();
178        let back: Describe = serde_json::from_str(&json).unwrap();
179        assert_eq!(d, back);
180        assert!(json.contains("\"capabilities\":[]"));
181    }
182
183    #[test]
184    fn rejects_json_without_capabilities() {
185        let json = r#"{"description":"test","version":"0.1.0","stability":"stable","min_dispatcher_version":"0.1.0","platforms":["linux"],"requires_cargo_workspace":false}"#;
186        assert!(serde_json::from_str::<Describe>(json).is_err());
187    }
188}