Skip to main content

roder_api/
distribution.rs

1use std::error::Error;
2use std::fmt;
3
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
7pub struct DistributionEntry {
8    pub id: String,
9    pub crate_name: String,
10    pub category: ExtensionCategory,
11    pub display_name: String,
12    pub description: String,
13    #[serde(default)]
14    pub default_in_profiles: Vec<String>,
15    #[serde(default)]
16    pub required_env: Vec<String>,
17    #[serde(default)]
18    pub optional_env: Vec<String>,
19    #[serde(default)]
20    pub conflicts_with: Vec<String>,
21    #[serde(default)]
22    pub required_capabilities: Vec<String>,
23    pub extension_path: String,
24    #[serde(default)]
25    pub docs_url: Option<String>,
26    #[serde(default)]
27    pub extras: serde_json::Value,
28}
29
30/**
31 * Extension category in distribution metadata. Serializes as a plain
32 * kebab-case string; unknown category names deserialize into
33 * [`ExtensionCategory::Other`] so new crates can declare novel categories
34 * (e.g. `inference-router`) without breaking older tooling.
35 */
36#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
37pub enum ExtensionCategory {
38    InferenceEngine,
39    WireDialect,
40    ThreadStore,
41    CheckpointStore,
42    MemoryStore,
43    EmbeddingProvider,
44    ContextProvider,
45    ContextPlanner,
46    ToolProvider,
47    PolicyContributor,
48    SandboxBackend,
49    EventSink,
50    TaskExecutor,
51    StatusSegment,
52    PaletteSource,
53    SpeechTranscriber,
54    SpeechSynthesizer,
55    MediaGenerator,
56    Other(String),
57}
58
59impl ExtensionCategory {
60    pub fn as_str(&self) -> &str {
61        match self {
62            Self::InferenceEngine => "inference-engine",
63            Self::WireDialect => "wire-dialect",
64            Self::ThreadStore => "thread-store",
65            Self::CheckpointStore => "checkpoint-store",
66            Self::MemoryStore => "memory-store",
67            Self::EmbeddingProvider => "embedding-provider",
68            Self::ContextProvider => "context-provider",
69            Self::ContextPlanner => "context-planner",
70            Self::ToolProvider => "tool-provider",
71            Self::PolicyContributor => "policy-contributor",
72            Self::SandboxBackend => "sandbox-backend",
73            Self::EventSink => "event-sink",
74            Self::TaskExecutor => "task-executor",
75            Self::StatusSegment => "status-segment",
76            Self::PaletteSource => "palette-source",
77            Self::SpeechTranscriber => "speech-transcriber",
78            Self::SpeechSynthesizer => "speech-synthesizer",
79            Self::MediaGenerator => "media-generator",
80            Self::Other(name) => name,
81        }
82    }
83
84    fn from_name(name: &str) -> Self {
85        match name {
86            "inference-engine" => Self::InferenceEngine,
87            "wire-dialect" => Self::WireDialect,
88            "thread-store" => Self::ThreadStore,
89            "checkpoint-store" => Self::CheckpointStore,
90            "memory-store" => Self::MemoryStore,
91            "embedding-provider" => Self::EmbeddingProvider,
92            "context-provider" => Self::ContextProvider,
93            "context-planner" => Self::ContextPlanner,
94            "tool-provider" => Self::ToolProvider,
95            "policy-contributor" => Self::PolicyContributor,
96            "sandbox-backend" => Self::SandboxBackend,
97            "event-sink" => Self::EventSink,
98            "task-executor" => Self::TaskExecutor,
99            "status-segment" => Self::StatusSegment,
100            "palette-source" => Self::PaletteSource,
101            "speech-transcriber" => Self::SpeechTranscriber,
102            "speech-synthesizer" => Self::SpeechSynthesizer,
103            "media-generator" => Self::MediaGenerator,
104            other => Self::Other(other.to_string()),
105        }
106    }
107}
108
109impl Serialize for ExtensionCategory {
110    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
111        serializer.serialize_str(self.as_str())
112    }
113}
114
115impl<'de> Deserialize<'de> for ExtensionCategory {
116    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
117        let name = String::deserialize(deserializer)?;
118        Ok(Self::from_name(&name))
119    }
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
123pub struct DistributionManifest {
124    pub name: String,
125    pub version: String,
126    pub include_tui: bool,
127    pub include_app_server: bool,
128    pub include_cli: bool,
129    #[serde(default)]
130    pub extensions: Vec<String>,
131    #[serde(default)]
132    pub default_provider: Option<String>,
133    #[serde(default)]
134    pub default_thread_store: Option<String>,
135    #[serde(default)]
136    pub config_overrides: serde_json::Value,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
140pub struct Profile {
141    pub id: String,
142    pub description: String,
143    pub manifest: DistributionManifest,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
147#[serde(tag = "kind", rename_all = "kebab-case")]
148pub enum CatalogError {
149    MissingMetadata {
150        crate_name: String,
151        manifest_path: Option<String>,
152    },
153    MalformedMetadata {
154        crate_name: String,
155        manifest_path: Option<String>,
156        message: String,
157    },
158    Conflict {
159        first_id: String,
160        second_id: String,
161        reason: String,
162    },
163    CapabilityDisabled {
164        extension_id: String,
165        capability: String,
166    },
167}
168
169impl fmt::Display for CatalogError {
170    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171        match self {
172            Self::MissingMetadata {
173                crate_name,
174                manifest_path,
175            } => match manifest_path {
176                Some(path) => write!(
177                    f,
178                    "crate `{crate_name}` has no [package.metadata.roder.distribution] metadata in {path}"
179                ),
180                None => write!(
181                    f,
182                    "crate `{crate_name}` has no [package.metadata.roder.distribution] metadata"
183                ),
184            },
185            Self::MalformedMetadata {
186                crate_name,
187                manifest_path,
188                message,
189            } => match manifest_path {
190                Some(path) => write!(
191                    f,
192                    "crate `{crate_name}` has malformed distribution metadata in {path}: {message}"
193                ),
194                None => write!(
195                    f,
196                    "crate `{crate_name}` has malformed distribution metadata: {message}"
197                ),
198            },
199            Self::Conflict {
200                first_id,
201                second_id,
202                reason,
203            } => write!(
204                f,
205                "distribution entries `{first_id}` and `{second_id}` conflict: {reason}"
206            ),
207            Self::CapabilityDisabled {
208                extension_id,
209                capability,
210            } => write!(
211                f,
212                "distribution entry `{extension_id}` requires disabled capability `{capability}`"
213            ),
214        }
215    }
216}
217
218impl Error for CatalogError {}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn distribution_entry_round_trips_json() {
226        let entry = DistributionEntry {
227            id: "openai-responses".to_string(),
228            crate_name: "roder-ext-openai-responses".to_string(),
229            category: ExtensionCategory::InferenceEngine,
230            display_name: "OpenAI Responses".to_string(),
231            description: "OpenAI Responses-style inference".to_string(),
232            default_in_profiles: vec!["full".to_string(), "openai-only".to_string()],
233            required_env: vec!["OPENAI_API_KEY".to_string()],
234            optional_env: vec!["OPENAI_BASE_URL".to_string()],
235            conflicts_with: vec![],
236            required_capabilities: vec![
237                "network.api.openai.com".to_string(),
238                "secret.read.OPENAI_API_KEY".to_string(),
239            ],
240            extension_path: "::extension".to_string(),
241            docs_url: Some("https://platform.openai.com/docs/api-reference/responses".to_string()),
242            extras: serde_json::json!({ "reasoning": true }),
243        };
244
245        let encoded = serde_json::to_value(&entry).unwrap();
246        assert_eq!(encoded["category"], "inference-engine");
247        let decoded: DistributionEntry = serde_json::from_value(encoded).unwrap();
248
249        assert_eq!(decoded, entry);
250    }
251
252    #[test]
253    fn extension_category_other_remains_extensible() {
254        // Novel category names (declared by newer crates) parse into
255        // `Other` and round-trip as the same plain string, so older
256        // tooling never fails on metadata it has not heard of.
257        for name in ["browser-automation", "inference-router"] {
258            let decoded: ExtensionCategory =
259                serde_json::from_value(serde_json::json!(name)).unwrap();
260            assert_eq!(decoded, ExtensionCategory::Other(name.to_string()));
261            assert_eq!(decoded.as_str(), name);
262            assert_eq!(
263                serde_json::to_value(decoded).unwrap(),
264                serde_json::json!(name)
265            );
266        }
267        // Known categories still parse into their variants.
268        let known: ExtensionCategory =
269            serde_json::from_value(serde_json::json!("inference-engine")).unwrap();
270        assert_eq!(known, ExtensionCategory::InferenceEngine);
271    }
272
273    #[test]
274    fn speech_extension_categories_parse_from_metadata() {
275        #[derive(Deserialize)]
276        struct CategoryFixture {
277            category: ExtensionCategory,
278        }
279
280        let transcriber: CategoryFixture =
281            toml::from_str(r#"category = "speech-transcriber""#).unwrap();
282        let synthesizer: CategoryFixture =
283            toml::from_str(r#"category = "speech-synthesizer""#).unwrap();
284
285        assert_eq!(transcriber.category, ExtensionCategory::SpeechTranscriber);
286        assert_eq!(synthesizer.category, ExtensionCategory::SpeechSynthesizer);
287    }
288
289    #[test]
290    fn distribution_manifest_and_profile_round_trip() {
291        let profile = Profile {
292            id: "research-headless".to_string(),
293            description: "Headless app-server distribution".to_string(),
294            manifest: DistributionManifest {
295                name: "research-roder".to_string(),
296                version: "0.1.0".to_string(),
297                include_tui: false,
298                include_app_server: true,
299                include_cli: true,
300                extensions: vec!["jsonl-thread-store".to_string(), "memory".to_string()],
301                default_provider: Some("openai-responses".to_string()),
302                default_thread_store: Some("jsonl-thread-store".to_string()),
303                config_overrides: serde_json::json!({
304                    "subagents": { "max_depth": 1 }
305                }),
306            },
307        };
308
309        let encoded = serde_json::to_string(&profile).unwrap();
310        let decoded: Profile = serde_json::from_str(&encoded).unwrap();
311
312        assert_eq!(decoded, profile);
313    }
314
315    #[test]
316    fn catalog_error_messages_are_actionable() {
317        let error = CatalogError::MalformedMetadata {
318            crate_name: "roder-ext-test".to_string(),
319            manifest_path: Some("crates/roder-ext-test/Cargo.toml".to_string()),
320            message: "missing field `display_name`".to_string(),
321        };
322
323        let message = error.to_string();
324        assert!(message.contains("roder-ext-test"));
325        assert!(message.contains("Cargo.toml"));
326        assert!(message.contains("display_name"));
327    }
328
329    #[test]
330    fn distribution_entry_parses_from_cargo_metadata_toml_shape() {
331        let toml = r#"
332id = "openai-responses"
333crate_name = "roder-ext-openai-responses"
334category = "inference-engine"
335display_name = "OpenAI Responses"
336description = "OpenAI Responses-style inference."
337default_in_profiles = ["full", "openai-only"]
338required_env = ["OPENAI_API_KEY"]
339optional_env = ["OPENAI_BASE_URL"]
340conflicts_with = []
341required_capabilities = ["network.api.openai.com", "secret.read.OPENAI_API_KEY"]
342extension_path = "::extension"
343docs_url = "https://platform.openai.com/docs/api-reference/responses"
344"#;
345
346        let entry: DistributionEntry = toml::from_str(toml).unwrap();
347
348        assert_eq!(entry.id, "openai-responses");
349        assert_eq!(entry.category, ExtensionCategory::InferenceEngine);
350        assert_eq!(entry.extension_path, "::extension");
351    }
352}