1use std::path::Path;
28
29use serde::{Deserialize, Serialize};
30use serde_json::{Map, Value};
31
32use crate::catalog::Catalog;
33use crate::error::{Error, Result};
34use crate::toolkit::schema::SchemaBundle;
35
36#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
40#[serde(rename_all = "camelCase")]
41pub struct ClientCapabilities {
42 #[serde(default)]
44 pub supported_catalog_ids: Vec<String>,
45 #[serde(default)]
47 pub inline_catalogs: Vec<Value>,
48}
49
50impl ClientCapabilities {
51 pub fn supporting<I, S>(ids: I) -> Self
53 where
54 I: IntoIterator<Item = S>,
55 S: Into<String>,
56 {
57 Self {
58 supported_catalog_ids: ids.into_iter().map(Into::into).collect(),
59 inline_catalogs: Vec::new(),
60 }
61 }
62}
63
64pub fn select_catalog_schema(
77 supported: &[Value],
78 capabilities: &ClientCapabilities,
79 accepts_inline: bool,
80) -> Result<Value> {
81 if !capabilities.inline_catalogs.is_empty() && !accepts_inline {
82 return Err(Error::catalog(
83 "the renderer supplied inline catalogs but the agent does not accept inline catalogs",
84 ));
85 }
86
87 let matched = match_by_preference(supported, &capabilities.supported_catalog_ids);
88 let base = match matched {
89 Some(catalog) => catalog,
90 None if !capabilities.inline_catalogs.is_empty() => supported
93 .first()
94 .ok_or_else(|| Error::catalog("the agent has no catalogs to offer"))?,
95 None if capabilities.supported_catalog_ids.is_empty() => supported
96 .first()
97 .ok_or_else(|| Error::catalog("the agent has no catalogs to offer"))?,
98 None => {
99 return Err(Error::catalog(format!(
100 "No client-supported catalog found: the renderer supports [{}], the agent offers \
101 [{}]",
102 capabilities.supported_catalog_ids.join(", "),
103 supported
104 .iter()
105 .filter_map(|catalog| catalog.get("catalogId").and_then(Value::as_str))
106 .collect::<Vec<_>>()
107 .join(", ")
108 )));
109 }
110 };
111
112 let mut selected = base.clone();
113 for inline in &capabilities.inline_catalogs {
114 merge_components(&mut selected, inline);
115 }
116 Ok(selected)
117}
118
119pub fn select_catalog(
126 supported: &[Value],
127 capabilities: &ClientCapabilities,
128 accepts_inline: bool,
129) -> Result<Catalog> {
130 Catalog::from_schema(&select_catalog_schema(
131 supported,
132 capabilities,
133 accepts_inline,
134 )?)
135}
136
137fn match_by_preference<'a>(supported: &'a [Value], preferred: &[String]) -> Option<&'a Value> {
138 preferred.iter().find_map(|id| {
139 supported
140 .iter()
141 .find(|catalog| catalog.get("catalogId").and_then(Value::as_str) == Some(id.as_str()))
142 })
143}
144
145fn merge_components(target: &mut Value, source: &Value) {
147 let Some(target) = target.as_object_mut() else {
148 return;
149 };
150 for section in ["components", "functions"] {
151 let Some(Value::Object(extra)) = source.get(section) else {
152 continue;
153 };
154 let entry = target
155 .entry(section.to_string())
156 .or_insert_with(|| Value::Object(Map::new()));
157 if let Some(existing) = entry.as_object_mut() {
158 for (name, definition) in extra {
159 existing.insert(name.clone(), definition.clone());
160 }
161 }
162 }
163}
164
165#[derive(Debug, Clone, Default)]
172pub struct CatalogRegistry {
173 entries: Vec<RegistryEntry>,
174}
175
176#[derive(Debug, Clone, PartialEq)]
178pub struct RegistryEntry {
179 pub name: String,
181 pub schema: Value,
183}
184
185impl RegistryEntry {
186 pub fn catalog_id(&self) -> &str {
188 self.schema
189 .get("catalogId")
190 .and_then(Value::as_str)
191 .unwrap_or(&self.name)
192 }
193}
194
195impl CatalogRegistry {
196 pub fn new() -> Self {
198 Self::default()
199 }
200
201 pub fn insert(&mut self, name: impl Into<String>, schema: Value) {
203 self.entries.push(RegistryEntry {
204 name: name.into(),
205 schema,
206 });
207 }
208
209 pub fn load(&mut self, name: impl Into<String>, path: impl AsRef<Path>) -> Result<()> {
215 let path = path.as_ref();
216 let text = std::fs::read_to_string(path)
217 .map_err(|e| Error::catalog(format!("cannot read catalog {}: {e}", path.display())))?;
218 let schema = serde_json::from_str(&text)
219 .map_err(|e| Error::catalog(format!("catalog {} is not JSON: {e}", path.display())))?;
220 self.insert(name, schema);
221 Ok(())
222 }
223
224 pub fn relax_strict_validation(&mut self) {
229 for entry in &mut self.entries {
230 crate::toolkit::schema::remove_strict_validation(&mut entry.schema);
231 }
232 }
233
234 pub fn entries(&self) -> &[RegistryEntry] {
236 &self.entries
237 }
238
239 pub fn schemas(&self) -> Vec<Value> {
241 self.entries
242 .iter()
243 .map(|entry| entry.schema.clone())
244 .collect()
245 }
246
247 pub fn supported_catalog_ids(&self) -> Vec<String> {
249 self.entries
250 .iter()
251 .map(|entry| entry.catalog_id().to_string())
252 .collect()
253 }
254
255 pub fn get(&self, id: &str) -> Option<&RegistryEntry> {
257 self.entries
258 .iter()
259 .find(|entry| entry.catalog_id() == id || entry.name == id)
260 }
261
262 pub fn select(
268 &self,
269 capabilities: &ClientCapabilities,
270 accepts_inline: bool,
271 ) -> Result<SchemaBundle> {
272 let schema = select_catalog_schema(&self.schemas(), capabilities, accepts_inline)?;
273 Ok(SchemaBundle::from_catalog(schema))
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280 use serde_json::json;
281
282 fn agent_catalogs() -> Vec<Value> {
283 vec![
284 json!({"catalogId": "id_basic", "components": {}}),
285 json!({"catalogId": "id_custom1", "components": {}}),
286 json!({"catalogId": "id_custom2", "components": {}}),
287 ]
288 }
289
290 #[test]
291 fn no_preference_takes_the_agents_default() {
292 let chosen =
293 select_catalog_schema(&agent_catalogs(), &ClientCapabilities::default(), false)
294 .unwrap();
295 assert_eq!(chosen["catalogId"], "id_basic");
296 }
297
298 #[test]
299 fn the_renderers_order_decides() {
300 let chosen = select_catalog_schema(
301 &agent_catalogs(),
302 &ClientCapabilities::supporting(["id_custom2", "id_custom1"]),
303 false,
304 )
305 .unwrap();
306 assert_eq!(chosen["catalogId"], "id_custom2");
307
308 let chosen = select_catalog_schema(
309 &agent_catalogs(),
310 &ClientCapabilities::supporting(["id_custom1", "id_custom2"]),
311 false,
312 )
313 .unwrap();
314 assert_eq!(chosen["catalogId"], "id_custom1");
315 }
316
317 #[test]
318 fn no_overlap_is_an_error() {
319 let error = select_catalog_schema(
320 &agent_catalogs(),
321 &ClientCapabilities::supporting(["id_not_exists"]),
322 false,
323 )
324 .unwrap_err();
325 assert!(
326 error
327 .to_string()
328 .contains("No client-supported catalog found")
329 );
330 }
331
332 #[test]
333 fn inline_catalogs_extend_the_selection() {
334 let capabilities = ClientCapabilities {
335 supported_catalog_ids: vec![],
336 inline_catalogs: vec![json!({"catalogId": "id_inline", "components": {"Button": {}}})],
337 };
338 let chosen = select_catalog_schema(
339 &[json!({"catalogId": "id_basic", "components": {"Text": {}}})],
340 &capabilities,
341 true,
342 )
343 .unwrap();
344 assert_eq!(
346 chosen,
347 json!({"catalogId": "id_basic", "components": {"Text": {}, "Button": {}}})
348 );
349 }
350
351 #[test]
352 fn several_inline_catalogs_merge_in_order() {
353 let capabilities = ClientCapabilities {
354 supported_catalog_ids: vec![],
355 inline_catalogs: vec![
356 json!({"catalogId": "id_basic", "components": {"Button": {}}}),
357 json!({"catalogId": "id_basic", "components": {"Icon": {}}}),
358 ],
359 };
360 let chosen = select_catalog_schema(
361 &[json!({"catalogId": "id_basic", "components": {"Text": {}}})],
362 &capabilities,
363 true,
364 )
365 .unwrap();
366 assert_eq!(
367 chosen,
368 json!({"catalogId": "id_basic", "components": {"Text": {}, "Button": {}, "Icon": {}}})
369 );
370 }
371
372 #[test]
373 fn inline_catalogs_rescue_a_failed_match() {
374 let capabilities = ClientCapabilities {
375 supported_catalog_ids: vec!["id_not_exists".to_string()],
376 inline_catalogs: vec![json!({"catalogId": "id_basic", "components": {"Button": {}}})],
377 };
378 let chosen = select_catalog_schema(
379 &[
380 json!({"catalogId": "id_basic", "components": {"Text": {}}}),
381 json!({"catalogId": "id_custom1", "components": {}}),
382 ],
383 &capabilities,
384 true,
385 )
386 .unwrap();
387 assert_eq!(
388 chosen,
389 json!({"catalogId": "id_basic", "components": {"Text": {}, "Button": {}}})
390 );
391 }
392
393 #[test]
394 fn inline_catalogs_are_refused_when_the_agent_says_so() {
395 let capabilities = ClientCapabilities {
396 supported_catalog_ids: vec![],
397 inline_catalogs: vec![json!({"catalogId": "id_inline"})],
398 };
399 let error =
400 select_catalog_schema(&[json!({"catalogId": "id_basic"})], &capabilities, false)
401 .unwrap_err();
402 assert!(
403 error
404 .to_string()
405 .contains("the agent does not accept inline catalogs")
406 );
407 }
408
409 #[test]
410 fn a_registry_reports_wire_ids_not_local_names() {
411 let mut registry = CatalogRegistry::new();
412 registry.insert("standard", json!({"catalogId": "basic"}));
413 registry.insert("Custom", json!({"components": {}}));
414 assert_eq!(
415 registry.supported_catalog_ids(),
416 vec!["basic".to_string(), "Custom".to_string()]
417 );
418 assert!(registry.get("basic").is_some());
419 assert!(registry.get("standard").is_some());
420 assert!(registry.get("nope").is_none());
421 }
422
423 #[test]
424 fn relaxing_a_registry_strips_strict_validation() {
425 let mut registry = CatalogRegistry::new();
426 registry.insert(
427 "strict",
428 json!({"catalogId": "s", "components": {"Text": {"additionalProperties": false}}}),
429 );
430 registry.relax_strict_validation();
431 assert_eq!(
432 registry.entries()[0].schema,
433 json!({"catalogId": "s", "components": {"Text": {}}})
434 );
435 }
436}