1#![doc = include_str!("../README.md")]
2#![no_std]
3
4extern crate alloc;
5
6use alloc::borrow::ToOwned;
7use alloc::collections::BTreeMap;
8use alloc::string::String;
9use alloc::vec;
10use alloc::vec::Vec;
11
12use schemars::{JsonSchema, schema_for};
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16pub const DEFAULT_SCHEMA_URL: &str =
18 "https://catalog.lintel.tools/schemas/lintel/catalog/latest.json";
19
20#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
28#[schemars(title = "Schema Catalog")]
29pub struct Catalog {
30 #[serde(default, rename = "$schema")]
32 pub schema: Option<String>,
33 pub version: u32,
35 #[serde(default)]
37 pub title: Option<String>,
38 pub schemas: Vec<SchemaEntry>,
40 #[serde(default)]
44 pub groups: Vec<CatalogGroup>,
45}
46
47impl Serialize for Catalog {
48 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
49 use serde::ser::SerializeMap;
50
51 let mut len = 3; if self.title.is_some() {
53 len += 1;
54 }
55 if !self.groups.is_empty() {
56 len += 1;
57 }
58
59 let mut map = serializer.serialize_map(Some(len))?;
60 map.serialize_entry(
61 "$schema",
62 self.schema.as_deref().unwrap_or(DEFAULT_SCHEMA_URL),
63 )?;
64 map.serialize_entry("version", &self.version)?;
65 if let Some(ref title) = self.title {
66 map.serialize_entry("title", title)?;
67 }
68 map.serialize_entry("schemas", &self.schemas)?;
69 if !self.groups.is_empty() {
70 map.serialize_entry("groups", &self.groups)?;
71 }
72 map.end()
73 }
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
81#[schemars(title = "Schema Group")]
82pub struct CatalogGroup {
83 pub name: String,
85 pub description: String,
87 pub schemas: Vec<String>,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
95#[schemars(title = "Schema Entry")]
96pub struct SchemaEntry {
97 #[schemars(example = example_schema_name())]
99 pub name: String,
100 pub description: String,
102 #[schemars(example = example_schema_url())]
104 pub url: String,
105 #[serde(default, rename = "sourceUrl", skip_serializing_if = "Option::is_none")]
108 pub source_url: Option<String>,
109 #[serde(default, rename = "fileMatch", skip_serializing_if = "Vec::is_empty")]
114 #[schemars(title = "File Match")]
115 #[schemars(example = example_file_match())]
116 pub file_match: Vec<String>,
117 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
120 pub versions: BTreeMap<String, String>,
121}
122
123fn example_schema_name() -> String {
124 "My Config".to_owned()
125}
126
127fn example_schema_url() -> String {
128 "https://example.com/schemas/my-config.json".to_owned()
129}
130
131fn example_file_match() -> Vec<String> {
132 vec!["*.config.json".to_owned(), "**/.config.json".to_owned()]
133}
134
135pub fn schema() -> Value {
141 serde_json::to_value(schema_for!(Catalog)).expect("schema serialization cannot fail")
142}
143
144pub fn parse_catalog(json: &str) -> Result<Catalog, serde_json::Error> {
150 serde_json::from_str(json)
151}
152
153pub fn parse_catalog_value(value: serde_json::Value) -> Result<Catalog, serde_json::Error> {
159 serde_json::from_value(value)
160}
161
162#[cfg(test)]
163mod tests {
164 use alloc::string::ToString;
165
166 use super::*;
167
168 #[test]
169 fn round_trip_catalog() {
170 let catalog = Catalog {
171 version: 1,
172 schemas: vec![SchemaEntry {
173 name: "Test Schema".into(),
174 description: "A test schema".into(),
175 url: "https://example.com/test.json".into(),
176 source_url: None,
177 file_match: vec!["*.test.json".into()],
178 versions: BTreeMap::new(),
179 }],
180 ..Catalog::default()
181 };
182 let json = serde_json::to_string_pretty(&catalog).expect("serialize");
183 let parsed: Catalog = serde_json::from_str(&json).expect("deserialize");
184 assert_eq!(parsed.version, 1);
185 assert_eq!(parsed.schemas.len(), 1);
186 assert_eq!(parsed.schemas[0].name, "Test Schema");
187 assert_eq!(parsed.schemas[0].file_match, vec!["*.test.json"]);
188 }
189
190 #[test]
191 fn parse_catalog_from_json_string() {
192 let json = r#"{"version":1,"schemas":[{"name":"test","description":"desc","url":"https://example.com/s.json","fileMatch":["*.json"]}]}"#;
193 let catalog = parse_catalog(json).expect("parse");
194 assert_eq!(catalog.schemas.len(), 1);
195 assert_eq!(catalog.schemas[0].name, "test");
196 assert_eq!(catalog.schemas[0].file_match, vec!["*.json"]);
197 }
198
199 #[test]
200 fn empty_file_match_omitted_in_serialization() {
201 let entry = SchemaEntry {
202 name: "No Match".into(),
203 description: "desc".into(),
204 url: "https://example.com/no.json".into(),
205 source_url: None,
206 file_match: vec![],
207 versions: BTreeMap::new(),
208 };
209 let json = serde_json::to_string(&entry).expect("serialize");
210 assert!(!json.contains("fileMatch"));
211 assert!(!json.contains("sourceUrl"));
212 assert!(!json.contains("versions"));
213 }
214
215 #[test]
216 fn source_url_serialized_as_camel_case() {
217 let entry = SchemaEntry {
218 name: "Test".into(),
219 description: "desc".into(),
220 url: "https://catalog.example.com/test.json".into(),
221 source_url: Some("https://upstream.example.com/test.json".into()),
222 file_match: vec![],
223 versions: BTreeMap::new(),
224 };
225 let json = serde_json::to_string(&entry).expect("serialize");
226 assert!(json.contains("\"sourceUrl\""));
227 assert!(json.contains("https://upstream.example.com/test.json"));
228
229 let parsed: SchemaEntry = serde_json::from_str(&json).expect("deserialize");
231 assert_eq!(
232 parsed.source_url.as_deref(),
233 Some("https://upstream.example.com/test.json")
234 );
235 }
236
237 #[test]
238 fn deserialize_with_versions() {
239 let json = r#"{
240 "version": 1,
241 "schemas": [{
242 "name": "test",
243 "description": "desc",
244 "url": "https://example.com/s.json",
245 "versions": {"draft-07": "https://example.com/draft07.json"}
246 }]
247 }"#;
248 let catalog = parse_catalog(json).expect("parse");
249 assert_eq!(
250 catalog.schemas[0].versions.get("draft-07"),
251 Some(&"https://example.com/draft07.json".to_string())
252 );
253 }
254
255 #[test]
256 fn schema_has_camel_case_properties() {
257 let schema = schema();
258 let schema_str = serde_json::to_string(&schema).expect("serialize");
259 assert!(
260 schema_str.contains("fileMatch"),
261 "schema should contain fileMatch"
262 );
263 assert!(
264 schema_str.contains("sourceUrl"),
265 "schema should contain sourceUrl"
266 );
267 }
268}