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#[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 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 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}