Skip to main content

curie_plugin/
lib.rs

1//! Shared manifest types and helpers for Curie plugins.
2//!
3//! Both sides of the plugin protocol use these types:
4//! - **Plugin binary**: constructs and serializes a [`Manifest`] on stdout.
5//! - **curie-build**: deserializes the [`Manifest`] from the plugin's stdout.
6//!
7//! Plugins also receive a typed [`Envelope`] on stdin; use [`read_envelope`]
8//! to parse it.
9
10use anyhow::Result;
11use serde::{Deserialize, Serialize};
12use std::collections::BTreeMap;
13use std::path::PathBuf;
14
15// ── Manifest ─────────────────────────────────────────────────────────────────
16
17/// Top-level manifest returned by `curie-<name> manifest` on stdout.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct Manifest {
20    pub name: String,
21    pub description: String,
22    pub version: String,
23    #[serde(default)]
24    pub types: Vec<String>,
25    #[serde(default)]
26    pub inputs: Inputs,
27    #[serde(default)]
28    pub outputs: Outputs,
29    #[serde(default)]
30    pub artifacts: Vec<Artifact>,
31}
32
33/// Source files that trigger re-generation when they change.
34///
35/// At least one of `dirs` or `files` should be non-empty.
36#[derive(Debug, Clone, Serialize, Deserialize, Default)]
37pub struct Inputs {
38    /// Directories to watch for added/removed files.
39    #[serde(default)]
40    pub dirs: Vec<PathBuf>,
41    /// Optional regex filter applied to filenames inside `dirs`.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub file_regex: Option<String>,
44    /// Individual files to watch (used when the input is a single spec file).
45    #[serde(default)]
46    pub files: Vec<PathBuf>,
47}
48
49/// Directories written by the plugin that curie-build adds to the source path.
50#[derive(Debug, Clone, Serialize, Deserialize, Default)]
51pub struct Outputs {
52    #[serde(default)]
53    pub source_dirs: Vec<PathBuf>,
54}
55
56/// A Maven artifact the plugin needs curie-build to download.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct Artifact {
59    /// Logical name used as the key in the `generate-sources` envelope.
60    pub id: String,
61    pub group: String,
62    pub artifact: String,
63    pub version: String,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub classifier: Option<String>,
66    pub extension: String,
67    #[serde(default)]
68    pub executable: bool,
69}
70
71// ── Envelope ─────────────────────────────────────────────────────────────────
72
73/// Envelope sent by curie-build to the plugin on stdin.
74///
75/// `C` is the plugin-specific config type (e.g. `ProtobufConfig`).
76/// The `artifacts` map is only populated for `generate-sources` calls.
77#[derive(Debug, Deserialize)]
78pub struct Envelope<C> {
79    pub curie_version: String,
80    pub config: C,
81    #[serde(default)]
82    pub artifacts: BTreeMap<String, PathBuf>,
83}
84
85/// Read and parse the [`Envelope`] from stdin.
86pub fn read_envelope<C: serde::de::DeserializeOwned>() -> Result<Envelope<C>> {
87    let mut s = String::new();
88    std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
89    Ok(serde_json::from_str(&s)?)
90}
91
92// ── Tests ─────────────────────────────────────────────────────────────────────
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    fn minimal_manifest() -> Manifest {
99        Manifest {
100            name: "test".to_string(),
101            description: "A test plugin".to_string(),
102            version: "0.1.0".to_string(),
103            types: vec!["source-generator".to_string()],
104            inputs: Inputs {
105                dirs: vec![PathBuf::from("proto")],
106                file_regex: Some(r"\.proto$".to_string()),
107                files: vec![],
108            },
109            outputs: Outputs {
110                source_dirs: vec![PathBuf::from("target/generated-sources/test")],
111            },
112            artifacts: vec![Artifact {
113                id: "tool".to_string(),
114                group: "com.example".to_string(),
115                artifact: "tool".to_string(),
116                version: "1.0.0".to_string(),
117                classifier: Some("linux-x86_64".to_string()),
118                extension: "exe".to_string(),
119                executable: true,
120            }],
121        }
122    }
123
124    #[test]
125    fn manifest_roundtrips_through_json() {
126        let m = minimal_manifest();
127        let json = serde_json::to_string(&m).unwrap();
128        let m2: Manifest = serde_json::from_str(&json).unwrap();
129        assert_eq!(m2.name, "test");
130        assert_eq!(m2.inputs.dirs, vec![PathBuf::from("proto")]);
131        assert_eq!(m2.inputs.file_regex.as_deref(), Some(r"\.proto$"));
132        assert_eq!(m2.outputs.source_dirs, vec![PathBuf::from("target/generated-sources/test")]);
133        assert_eq!(m2.artifacts[0].classifier, Some("linux-x86_64".to_string()));
134    }
135
136    #[test]
137    fn missing_optional_fields_deserialize_as_defaults() {
138        let json = r#"{
139            "name": "mini",
140            "description": "desc",
141            "version": "0.1.0"
142        }"#;
143        let m: Manifest = serde_json::from_str(json).unwrap();
144        assert!(m.types.is_empty());
145        assert!(m.inputs.dirs.is_empty());
146        assert!(m.inputs.file_regex.is_none());
147        assert!(m.inputs.files.is_empty());
148        assert!(m.outputs.source_dirs.is_empty());
149        assert!(m.artifacts.is_empty());
150    }
151
152    #[test]
153    fn artifact_without_classifier_omits_field_in_json() {
154        let art = Artifact {
155            id: "cli".to_string(),
156            group: "org.example".to_string(),
157            artifact: "cli".to_string(),
158            version: "1.0".to_string(),
159            classifier: None,
160            extension: "jar".to_string(),
161            executable: false,
162        };
163        let json = serde_json::to_string(&art).unwrap();
164        assert!(!json.contains("classifier"), "classifier key must be absent: {json}");
165    }
166
167    #[test]
168    fn file_regex_none_omits_field_in_json() {
169        let inputs = Inputs {
170            dirs: vec![],
171            file_regex: None,
172            files: vec![PathBuf::from("spec.yaml")],
173        };
174        let json = serde_json::to_string(&inputs).unwrap();
175        assert!(!json.contains("file_regex"), "file_regex key must be absent: {json}");
176    }
177
178    #[test]
179    fn envelope_deserializes_config_and_artifacts() {
180        #[derive(Deserialize)]
181        struct MyConfig {
182            value: String,
183        }
184
185        let json = r#"{
186            "curie_version": "0.6.0",
187            "config": {"value": "hello"},
188            "artifacts": {"tool": "/path/to/tool"}
189        }"#;
190        let env: Envelope<MyConfig> = serde_json::from_str(json).unwrap();
191        assert_eq!(env.curie_version, "0.6.0");
192        assert_eq!(env.config.value, "hello");
193        assert_eq!(env.artifacts["tool"], PathBuf::from("/path/to/tool"));
194    }
195
196    #[test]
197    fn envelope_artifacts_defaults_to_empty() {
198        #[derive(Deserialize)]
199        struct MyConfig {
200            #[allow(dead_code)]
201            v: u32,
202        }
203
204        let json = r#"{"curie_version": "0.6.0", "config": {"v": 1}}"#;
205        let env: Envelope<MyConfig> = serde_json::from_str(json).unwrap();
206        assert!(env.artifacts.is_empty());
207    }
208}